mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 03:27:14 -06:00
LR-PROF-CAMERACOLOR: ARW camera looks bleach highlights and shift hue by brightness
A sunny ILCE-7M4 waterfall shot rendered yellow: sunlit rock came out cream, +4 b* in the brightest fifth of the picture. Camera JPEGs make shadows up to 1.4x more colourful and bleach highlights to 0.1-0.3x (a per-channel tone curve), which a luminance-only curve can't do. - CameraTone gains an 8-node chroma-by-display-luminance curve, fitted per file (bright pixels included) and kept only when it lowers the held-out error; applied after the tone map on CPU and GPU, keeping luminance. Old smart-preview headers load with an identity curve. - The hue/saturation table gets an sRGB-encoded value axis: dark yellows had to turn toward orange while bright yellow-greens turned toward green. It is applied after the baseline exposure, as it is fitted. - Matrix-only and matrix+table looks are completed (tone, chroma) and compared in gamma-encoded values; acceptance gates stay linear. 29 local ARWs vs their camera JPEGs: mean dE 4.23 -> 3.54, every file closer, saturated areas 7.65 -> 6.41. Render cache v10. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d4b73b7f64
commit
987d14b6ad
@@ -60,13 +60,16 @@ fn luma(p: [f64; 3]) -> f64 {
|
||||
p[0] * 0.2627 + p[1] * 0.6780 + p[2] * 0.0593
|
||||
}
|
||||
|
||||
/// The finish stage's tone map and chroma curve (`lightcraft_pipeline::finish`).
|
||||
fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] {
|
||||
let scene = scene.map(|v| v.max(0.0));
|
||||
let y = luma(scene);
|
||||
if y <= 0.0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
scene.map(|v| v * f64::from(tone.apply(y as f32)) / y)
|
||||
let o = f64::from(tone.apply(y as f32));
|
||||
let k = f64::from(tone.chroma_scale(o as f32));
|
||||
scene.map(|v| o + (v * o / y - o) * k)
|
||||
}
|
||||
|
||||
fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||||
@@ -74,13 +77,18 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||||
return None;
|
||||
}
|
||||
let mut pairs = Vec::new();
|
||||
// Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only.
|
||||
let mut bright = Vec::new();
|
||||
let mut colour = 0;
|
||||
for (input, output) in sensor.data.iter().zip(&reference.data) {
|
||||
let y = luminance_2020(*output);
|
||||
if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5)
|
||||
|| !output.iter().all(|v| v.is_finite() && *v > 0.004 && *v < 0.98)
|
||||
|| !(0.015..0.85).contains(&y)
|
||||
{
|
||||
if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) {
|
||||
continue;
|
||||
}
|
||||
if (0.015..=1.0).contains(&y) {
|
||||
bright.push((input.map(f64::from), output.map(f64::from)));
|
||||
}
|
||||
if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) {
|
||||
continue;
|
||||
}
|
||||
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
@@ -121,8 +129,10 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||||
if !matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0) {
|
||||
return None;
|
||||
}
|
||||
// Matrix + table when the table helps the held-out pixels, else the matrix alone.
|
||||
let mut best: Option<(f64, usize, CameraLook)> = None;
|
||||
// Matrix + table when the table helps the held-out pixels, else the matrix alone. Chosen by
|
||||
// error in gamma-encoded display values (closer to what is seen: in linear values a slightly
|
||||
// missed bright rock outweighs a clearly wrong dark shirt); the acceptance gates below stay linear.
|
||||
let mut best: Option<(f64, f64, usize, CameraLook)> = None;
|
||||
for hue_sat in [fit_hue_sat(&pairs, &matrix), None] {
|
||||
let correction = hue_sat.as_ref().and_then(HueSat::new);
|
||||
let colour = |x: [f64; 3]| {
|
||||
@@ -132,21 +142,25 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let Some(curve) = fit_tone(tone_pairs) else { continue };
|
||||
let tone = ToneMap::camera(&curve, 0.0, 0.0, 0.0);
|
||||
let mut after = 0.0;
|
||||
let mut samples = 0;
|
||||
let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() };
|
||||
if let Some(tone) = fit_chroma(&bright, &look) {
|
||||
look.tone = tone;
|
||||
}
|
||||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0);
|
||||
for (x, target) in pairs.iter().step_by(3) {
|
||||
let corrected = displayed(colour(*x), &tone);
|
||||
for c in 0..3 {
|
||||
after += (corrected[c] - target[c]).powi(2);
|
||||
linear += (corrected[c] - target[c]).powi(2);
|
||||
perceptual += (corrected[c].max(0.0).powf(1.0 / 2.2) - target[c].max(0.0).powf(1.0 / 2.2)).powi(2);
|
||||
samples += 1;
|
||||
}
|
||||
}
|
||||
if after.is_finite() && best.as_ref().is_none_or(|b| after < b.0) {
|
||||
best = Some((after, samples, CameraLook { matrix, tone: curve, hue_sat }));
|
||||
if linear.is_finite() && perceptual.is_finite() && best.as_ref().is_none_or(|b| perceptual < b.0) {
|
||||
best = Some((perceptual, linear, samples, look));
|
||||
}
|
||||
}
|
||||
let (after, samples, look) = best?;
|
||||
let (_, after, samples, look) = best?;
|
||||
let original_tone = ToneMap::new(0.0, 0.0, 0.0);
|
||||
let before: f64 = pairs
|
||||
.iter()
|
||||
@@ -218,9 +232,13 @@ fn hue_saturation(p: [f64; 3]) -> Option<(f64, f64)> {
|
||||
/// that want the opposite shift), saturation 0, 0.25 … 1. Value is not an axis: tone is fitted separately.
|
||||
const TABLE_HUES: usize = 72;
|
||||
const TABLE_SATS: usize = 5;
|
||||
/// Kernel widths around each table node (hue in degrees, saturation).
|
||||
/// Value levels (sRGB-encoded, as DNG tables with encoding 1): cameras turn dark yellows toward
|
||||
/// orange but bright yellow-greens toward green, which one shift per hue averages away.
|
||||
const TABLE_VALS: usize = 5;
|
||||
/// Kernel widths around each table node (hue in degrees, saturation, encoded value).
|
||||
const KERNEL_HUE: f64 = 2.5;
|
||||
const KERNEL_SAT: f64 = 0.15;
|
||||
const KERNEL_VAL: f64 = 0.12;
|
||||
/// Kernel weight at which a node keeps half of its estimate; sparse nodes shrink to identity.
|
||||
const SHRINK_WEIGHT: f64 = 5.0;
|
||||
|
||||
@@ -229,12 +247,14 @@ const SHRINK_WEIGHT: f64 = 5.0;
|
||||
/// Saturation 0 stays identity, so neutrals are never tinted.
|
||||
fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option<HsvTable> {
|
||||
let to = to_prophoto();
|
||||
let samples: Vec<(f64, f64, f64, f64)> = pairs
|
||||
let samples: Vec<(f64, f64, f64, f64, f64)> = pairs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i % 3 != 0)
|
||||
.filter_map(|(_, (x, y))| {
|
||||
let (hp, sp) = hue_saturation(to.apply(matrix.apply(*x)))?;
|
||||
let p = to.apply(matrix.apply(*x));
|
||||
let (hp, sp) = hue_saturation(p)?;
|
||||
let value = f64::from(lightcraft_color::transfer::linear_to_srgb(p[0].max(p[1]).max(p[2]).min(1.0) as f32));
|
||||
let (ht, st) = hue_saturation(to.apply(*y))?;
|
||||
// hue is meaningless near neutral
|
||||
if sp < 0.08 || st < 0.02 {
|
||||
@@ -242,45 +262,112 @@ fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option<HsvTable
|
||||
}
|
||||
let shift = ((ht - hp + 180.0).rem_euclid(360.0) - 180.0).clamp(-30.0, 30.0);
|
||||
let log_scale = (st / sp).ln().clamp(-0.7, 0.7);
|
||||
Some((hp, sp.min(1.0), shift, log_scale))
|
||||
Some((hp, sp.min(1.0), value, shift, log_scale))
|
||||
})
|
||||
.collect();
|
||||
if samples.len() < 64 {
|
||||
return None;
|
||||
}
|
||||
let mut data = vec![[0.0f32, 1.0, 1.0]; TABLE_HUES * TABLE_SATS];
|
||||
for h in 0..TABLE_HUES {
|
||||
let hue = h as f64 * 360.0 / TABLE_HUES as f64;
|
||||
for s in 1..TABLE_SATS {
|
||||
let sat = s as f64 / (TABLE_SATS - 1) as f64;
|
||||
let (mut weight, mut shift, mut log_scale) = (0.0, 0.0, 0.0);
|
||||
for &(hp, sp, dh, ls) in &samples {
|
||||
let dhue = (hp - hue + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if dhue.abs() > 4.0 * KERNEL_HUE {
|
||||
continue;
|
||||
let mut data = vec![[0.0f32, 1.0, 1.0]; TABLE_VALS * TABLE_HUES * TABLE_SATS];
|
||||
for v in 0..TABLE_VALS {
|
||||
let val = v as f64 / (TABLE_VALS - 1) as f64;
|
||||
for h in 0..TABLE_HUES {
|
||||
let hue = h as f64 * 360.0 / TABLE_HUES as f64;
|
||||
for s in 1..TABLE_SATS {
|
||||
let sat = s as f64 / (TABLE_SATS - 1) as f64;
|
||||
let (mut weight, mut shift, mut log_scale) = (0.0, 0.0, 0.0);
|
||||
for &(hp, sp, vp, dh, ls) in &samples {
|
||||
let dhue = (hp - hue + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if dhue.abs() > 4.0 * KERNEL_HUE {
|
||||
continue;
|
||||
}
|
||||
let d2 = (dhue / KERNEL_HUE).powi(2) + ((sp - sat) / KERNEL_SAT).powi(2) + ((vp - val) / KERNEL_VAL).powi(2);
|
||||
let k = (-0.5 * d2).exp() * sp;
|
||||
weight += k;
|
||||
shift += k * dh;
|
||||
log_scale += k * ls;
|
||||
}
|
||||
if weight > 0.0
|
||||
&& let Some(entry) = data.get_mut((v * TABLE_HUES + h) * TABLE_SATS + s)
|
||||
{
|
||||
let shrink = 1.0 / (weight + SHRINK_WEIGHT);
|
||||
*entry = [(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0];
|
||||
}
|
||||
let k = (-0.5 * ((dhue / KERNEL_HUE).powi(2) + ((sp - sat) / KERNEL_SAT).powi(2))).exp() * sp;
|
||||
weight += k;
|
||||
shift += k * dh;
|
||||
log_scale += k * ls;
|
||||
}
|
||||
if weight > 0.0
|
||||
&& let Some(entry) = data.get_mut(h * TABLE_SATS + s)
|
||||
{
|
||||
let shrink = 1.0 / (weight + SHRINK_WEIGHT);
|
||||
*entry = [(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0];
|
||||
}
|
||||
}
|
||||
}
|
||||
data.iter().all(|e| e.iter().all(|v| v.is_finite())).then_some(HsvTable {
|
||||
hue_divisions: TABLE_HUES,
|
||||
sat_divisions: TABLE_SATS,
|
||||
val_divisions: 1,
|
||||
val_divisions: TABLE_VALS,
|
||||
data,
|
||||
srgb_value: false,
|
||||
srgb_value: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Kernel width of the chroma curve's nodes (display luminance) and the weight at which a node
|
||||
/// keeps half of its estimate.
|
||||
const CHROMA_KERNEL: f64 = 0.08;
|
||||
const CHROMA_SHRINK: f64 = 2.0;
|
||||
|
||||
/// The look's tone curve with a chroma-by-display-luminance curve fitted to `pairs` (two thirds
|
||||
/// train, one third held out), when that lowers the held-out error. Colourfulness is compared as
|
||||
/// the distance from neutral of luminance-normalised RGB.
|
||||
fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option<CameraTone> {
|
||||
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
|
||||
let scene = |x: &[f64; 3]| {
|
||||
let p = look.matrix.apply(*x);
|
||||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||||
};
|
||||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let predict = |x: &[f64; 3]| displayed(scene(x), &tone);
|
||||
let chroma = |p: [f64; 3]| {
|
||||
let y = luma(p);
|
||||
(y > 0.0).then(|| p.iter().map(|v| (v / y - 1.0).powi(2)).sum::<f64>().sqrt())
|
||||
};
|
||||
let samples: Vec<(f64, f64, f64)> = pairs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i % 3 != 0)
|
||||
.filter_map(|(_, (x, y))| {
|
||||
let p = predict(x);
|
||||
let (cp, cy) = (chroma(p)?, chroma(*y)?);
|
||||
(cp > 0.05).then(|| (luma(p), cp, (cy.max(1e-3) / cp).ln().clamp(0.05f64.ln(), 2.5f64.ln())))
|
||||
})
|
||||
.collect();
|
||||
if samples.len() < 64 {
|
||||
return None;
|
||||
}
|
||||
let mut curve = [1.0f32; lightcraft_pipeline::tone::CHROMA_N];
|
||||
for (j, node) in curve.iter_mut().enumerate() {
|
||||
let at = j as f64 / (lightcraft_pipeline::tone::CHROMA_N - 1) as f64;
|
||||
let (mut weight, mut sum) = (0.0, 0.0);
|
||||
for &(o, cp, log_ratio) in &samples {
|
||||
let k = (-0.5 * ((o - at) / CHROMA_KERNEL).powi(2)).exp() * cp;
|
||||
weight += k;
|
||||
sum += k * log_ratio;
|
||||
}
|
||||
*node = (sum / (weight + CHROMA_SHRINK)).exp() as f32;
|
||||
}
|
||||
let fitted = look.tone.with_chroma(curve)?;
|
||||
let with = ToneMap::camera(&fitted, 0.0, 0.0, 0.0);
|
||||
let error = |map: &ToneMap| -> f64 {
|
||||
pairs
|
||||
.iter()
|
||||
.step_by(3)
|
||||
.map(|(x, y)| {
|
||||
let p = displayed(scene(x), map);
|
||||
(0..3).map(|c| (p[c] - y[c]).powi(2)).sum::<f64>()
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
let (before, after) = (error(&tone), error(&with));
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] ARW chroma curve {curve:?}: held-out error {before:.4} -> {after:.4}");
|
||||
}
|
||||
(after.is_finite() && after < before).then_some(fitted)
|
||||
}
|
||||
|
||||
fn median(values: &mut [f64]) -> Option<f64> {
|
||||
values.sort_by(f64::total_cmp);
|
||||
values.get(values.len() / 2).copied()
|
||||
@@ -447,6 +534,47 @@ mod tests {
|
||||
assert!((luminance_2020(hue_sat.apply(green)) - luminance_2020(green)).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// A camera that saturates shadows and bleaches highlights toward white (a per-channel curve),
|
||||
/// which a luminance tone curve can't: the fitted chroma curve must follow it, so bright
|
||||
/// warm-tinted rock renders white instead of cream.
|
||||
#[test]
|
||||
fn chroma_curve_follows_highlight_bleaching() {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let camera_chroma = |o: f32| 1.3 - 1.1 * o;
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let ev = 0.02 + (i % 37) as f32 * 0.03;
|
||||
*src = [ev * (0.75 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
let o = 1.0 - (-2.5 * y).exp();
|
||||
let k = camera_chroma(o);
|
||||
*dst = p.map(|v| (o + (v * o / y - o) * k).clamp(0.0, 0.999));
|
||||
}
|
||||
let fit = fit_pairs(&sensor, &reference).unwrap();
|
||||
// relative to the matrix, which already carries the average colourfulness
|
||||
let chroma = fit.tone.chroma();
|
||||
assert!(chroma[6] < 0.5 * chroma[1], "highlights bleach relative to shadows: {chroma:?}");
|
||||
// and the rendered highlights land on the camera's, far closer than without the curve
|
||||
let with = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
|
||||
let without = ToneMap::camera(&fit.tone.with_chroma([1.0; lightcraft_pipeline::tone::CHROMA_N]).unwrap(), 0.0, 0.0, 0.0);
|
||||
let highlight_error = |map: &ToneMap| -> f64 {
|
||||
sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.filter(|(_, y)| luminance_2020(**y) > 0.7)
|
||||
.map(|(x, y)| {
|
||||
let p = displayed(fit.matrix.apply(x.map(f64::from)), map);
|
||||
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>()
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
let (a, b) = (highlight_error(&without), highlight_error(&with));
|
||||
assert!(b < 0.5 * a, "highlight error {a:.4} -> {b:.4}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_passes_black_and_non_finite_pixels_through() {
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
|
||||
@@ -263,11 +263,12 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
let rgb = hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb));
|
||||
match &tables {
|
||||
Some(tables) => tables.apply(rgb, gain).map(|v| v.max(0.0)),
|
||||
None => rgb.map(|v| (v * gain).max(0.0)),
|
||||
}
|
||||
let rgb = match &tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
// after the baseline exposure, as when it was fitted
|
||||
hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
});
|
||||
stages.push(("colour", t0.elapsed()));
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||||
|
||||
@@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 9;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 10;
|
||||
|
||||
/// 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];
|
||||
|
||||
@@ -233,14 +233,16 @@ pub struct Gpu {
|
||||
/// WGSL constants shared by every module (generated from the CPU pipeline's values).
|
||||
fn constants() -> String {
|
||||
use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_SUMS, MASK_TERMS, SRGB_LUT_N};
|
||||
use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N};
|
||||
use lightcraft_pipeline::tone::{CHROMA_N, LUT_MAX_EV, LUT_MIN_EV, LUT_N};
|
||||
let mut s = String::new();
|
||||
let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices();
|
||||
for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab)] {
|
||||
let rows: Vec<String> = m.iter().map(|r| format!("vec3<f32>({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect();
|
||||
s += &format!("const {name} = array<vec3<f32>, 3>({});\n", rows.join(", "));
|
||||
}
|
||||
s += &format!("const TONE_MIN_EV: f32 = {LUT_MIN_EV:?};\nconst TONE_MAX_EV: f32 = {LUT_MAX_EV:?};\nconst TONE_N: u32 = {LUT_N}u;\n");
|
||||
s += &format!(
|
||||
"const TONE_MIN_EV: f32 = {LUT_MIN_EV:?};\nconst TONE_MAX_EV: f32 = {LUT_MAX_EV:?};\nconst TONE_N: u32 = {LUT_N}u;\nconst CHROMA_N: u32 = {CHROMA_N}u;\n"
|
||||
);
|
||||
s += &format!("const TONE_MIN_GAIN: f32 = {:?};\n", 2f32.powf(LUT_MIN_EV));
|
||||
let b = lightcraft_pipeline::geometry::BLANK;
|
||||
s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]);
|
||||
|
||||
@@ -136,10 +136,11 @@ pub struct Present {
|
||||
pub chroma: bool,
|
||||
}
|
||||
|
||||
/// The `finish` kernel's parameter block and auxiliary table (tone LUT | sRGB LUT | curve LUTs |
|
||||
/// The `finish` kernel's parameter block and auxiliary table (tone LUT | chroma curve | sRGB LUT | curve LUTs |
|
||||
/// mask terms) for `fp` with `masks` (their local adjustments' terms).
|
||||
pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Present) -> (Vec<u32>, Vec<f32>) {
|
||||
let mut aux: Vec<f32> = fp.tone.lut().to_vec();
|
||||
aux.extend_from_slice(fp.tone.chroma_lut());
|
||||
let srgb_off = aux.len();
|
||||
aux.extend_from_slice(&srgb_lut()[..]);
|
||||
let curve_off = aux.len();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The per-pixel stage: a straight port of `lightcraft_pipeline::finish` (keep in step with it).
|
||||
// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes, then the
|
||||
// blurred chromaticity when HAS_CHROMA), aux
|
||||
// (tone LUT | sRGB LUT | curve LUTs | mask terms), out (packed RGBA8).
|
||||
// (tone LUT | chroma curve | sRGB LUT | curve LUTs | mask terms), out (packed RGBA8).
|
||||
|
||||
fn tone_apply(y: f32) -> f32 {
|
||||
if (y <= 0.0) {
|
||||
@@ -18,6 +18,19 @@ fn tone_apply(y: f32) -> f32 {
|
||||
return v;
|
||||
}
|
||||
|
||||
// The camera chroma curve follows the tone LUT in `aux` (`ToneMap::chroma_scale`).
|
||||
fn chroma_scale(o: f32) -> f32 {
|
||||
let f = clamp(o, 0.0, 1.0) * f32(CHROMA_N - 1u);
|
||||
let i = min(u32(f), CHROMA_N - 2u);
|
||||
let t = f - f32(i);
|
||||
let a = aux[TONE_N + i];
|
||||
let b = aux[TONE_N + i + 1u];
|
||||
if (a == b) {
|
||||
return a;
|
||||
}
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
fn encode_srgb(v: f32) -> f32 {
|
||||
let o = pu(F_SRGB_OFF);
|
||||
let f = clamp(v, 0.0, 1.0) * f32(SRGB_N);
|
||||
@@ -400,6 +413,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (yl > 1e-9) {
|
||||
d = c * o / yl;
|
||||
}
|
||||
let k = chroma_scale(o);
|
||||
if (k != 1.0) {
|
||||
d = vec3<f32>(o) + (d - vec3<f32>(o)) * k;
|
||||
}
|
||||
let mx = max(d.x, max(d.y, d.z));
|
||||
if (mx > 1.0) {
|
||||
let t = clamp((mx - 1.0) / max(mx - o, 1e-6), 0.0, 1.0);
|
||||
|
||||
@@ -41,6 +41,11 @@ fn camera_tone_and_relative_wb() {
|
||||
s.wb.mode = WbMode::Custom;
|
||||
s.wb.temp = 8000.0;
|
||||
check("camera tone edited", &src, &info, &s, &RenderRequest::fit(320, 240));
|
||||
// a camera chroma curve: richer shadows, highlights bleached toward white
|
||||
let curve = curve.with_chroma([1.4, 1.3, 1.1, 1.0, 0.7, 0.4, 0.25, 0.2]).unwrap();
|
||||
let info = SourceInfo { camera_tone: Some(curve), ..info };
|
||||
check("camera chroma curve", &src, &info, &DevelopSettings::default(), &RenderRequest::fit(320, 240));
|
||||
check("camera chroma curve edited", &src, &info, &s, &RenderRequest::fit(320, 240));
|
||||
}
|
||||
|
||||
fn scene(i: usize, w: usize, h: usize) -> Arc<Rgb32f> {
|
||||
|
||||
@@ -483,6 +483,10 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
let yl = luminance_2020(c);
|
||||
let o = tone.apply(yl);
|
||||
let mut d = if yl > 1e-9 { c.map(|v| v * o / yl) } else { [0.0; 3] };
|
||||
let k = tone.chroma_scale(o);
|
||||
if k != 1.0 {
|
||||
d = d.map(|v| o + (v - o) * k);
|
||||
}
|
||||
let mx = d[0].max(d[1]).max(d[2]);
|
||||
if mx > 1.0 {
|
||||
let t = ((mx - 1.0) / (mx - o).max(1e-6)).clamp(0.0, 1.0);
|
||||
|
||||
@@ -13,13 +13,19 @@ pub const GREY: f32 = 0.18;
|
||||
pub const LUT_MIN_EV: f32 = -14.0;
|
||||
pub const LUT_MAX_EV: f32 = 10.0;
|
||||
pub const LUT_N: usize = 4096;
|
||||
/// Nodes of a camera chroma curve, evenly spaced over display luminance 0..=1.
|
||||
pub const CHROMA_N: usize = 8;
|
||||
const NO_CHROMA: [f32; CHROMA_N] = [1.0; CHROMA_N];
|
||||
|
||||
/// A file-local camera look, fitted independently of the scene-linear colour transform.
|
||||
/// Knots are scene/display-linear luminance pairs. Keeping this in the finish stage preserves
|
||||
/// RAW exposure and highlight headroom; it is never baked into the decoded sensor pixels.
|
||||
/// `chroma` scales colourfulness by display luminance after the curve (a camera's per-channel
|
||||
/// curve saturates shadows and bleaches highlights toward white, which a luminance curve can't).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct CameraTone {
|
||||
knots: [[f32; 2]; 32],
|
||||
chroma: [f32; CHROMA_N],
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for CameraTone {
|
||||
@@ -27,9 +33,16 @@ impl<'de> serde::Deserialize<'de> for CameraTone {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Wire {
|
||||
knots: [[f32; 2]; 32],
|
||||
// absent in smart previews written before the chroma curve existed
|
||||
#[serde(default)]
|
||||
chroma: Option<[f32; CHROMA_N]>,
|
||||
}
|
||||
let w = Wire::deserialize(d)?;
|
||||
Self::new(w.knots).ok_or_else(|| serde::de::Error::custom("invalid camera tone curve"))
|
||||
let tone = Self::new(w.knots).ok_or_else(|| serde::de::Error::custom("invalid camera tone curve"))?;
|
||||
match w.chroma {
|
||||
Some(c) => tone.with_chroma(c).ok_or_else(|| serde::de::Error::custom("invalid camera chroma curve")),
|
||||
None => Ok(tone),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +55,16 @@ impl CameraTone {
|
||||
}
|
||||
previous = p;
|
||||
}
|
||||
Some(Self { knots })
|
||||
Some(Self { knots, chroma: NO_CHROMA })
|
||||
}
|
||||
|
||||
/// The curve with chroma scales at display luminance 0, 1/7 … 1 (each finite, 0..=4).
|
||||
pub fn with_chroma(self, chroma: [f32; CHROMA_N]) -> Option<Self> {
|
||||
chroma.iter().all(|k| k.is_finite() && (0.0..=4.0).contains(k)).then_some(Self { chroma, ..self })
|
||||
}
|
||||
|
||||
pub fn chroma(&self) -> &[f32; CHROMA_N] {
|
||||
&self.chroma
|
||||
}
|
||||
|
||||
pub fn apply(&self, y: f32) -> f32 {
|
||||
@@ -68,6 +90,7 @@ impl CameraTone {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ToneMap {
|
||||
lut: Vec<f32>,
|
||||
chroma: [f32; CHROMA_N],
|
||||
}
|
||||
|
||||
impl ToneMap {
|
||||
@@ -81,7 +104,7 @@ impl ToneMap {
|
||||
if neutral { y } else { adjustment.apply(y) }
|
||||
})
|
||||
.collect();
|
||||
ToneMap { lut }
|
||||
ToneMap { lut, chroma: curve.chroma }
|
||||
}
|
||||
/// `contrast`, `whites`, `blacks` in −100..100 (Lightroom slider units).
|
||||
pub fn new(contrast: f64, whites: f64, blacks: f64) -> ToneMap {
|
||||
@@ -113,7 +136,7 @@ impl ToneMap {
|
||||
o.clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
ToneMap { lut }
|
||||
ToneMap { lut, chroma: NO_CHROMA }
|
||||
}
|
||||
|
||||
/// Tone map for display-referred sources: identity at neutral settings.
|
||||
@@ -147,7 +170,7 @@ impl ToneMap {
|
||||
o.clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
ToneMap { lut }
|
||||
ToneMap { lut, chroma: NO_CHROMA }
|
||||
}
|
||||
|
||||
/// The table (`LUT_N` entries, see [`ToneMap::apply`]).
|
||||
@@ -155,6 +178,24 @@ impl ToneMap {
|
||||
&self.lut
|
||||
}
|
||||
|
||||
/// The chroma curve (`CHROMA_N` entries, see [`ToneMap::chroma_scale`]).
|
||||
pub fn chroma_lut(&self) -> &[f32] {
|
||||
&self.chroma
|
||||
}
|
||||
|
||||
/// Chroma scale at display luminance `o` (exactly 1 everywhere unless a camera look sets it).
|
||||
#[inline]
|
||||
pub fn chroma_scale(&self, o: f32) -> f32 {
|
||||
if !o.is_finite() {
|
||||
return 1.0;
|
||||
}
|
||||
let f = o.clamp(0.0, 1.0) * (CHROMA_N - 1) as f32;
|
||||
let i = (f as usize).min(CHROMA_N - 2);
|
||||
let t = f - i as f32;
|
||||
let (a, b) = (self.chroma.get(i).copied().unwrap_or(1.0), self.chroma.get(i + 1).copied().unwrap_or(1.0));
|
||||
if a == b { a } else { a + (b - a) * t }
|
||||
}
|
||||
|
||||
/// Scene luminance → display-linear luminance.
|
||||
#[inline]
|
||||
pub fn apply(&self, y: f32) -> f32 {
|
||||
@@ -179,6 +220,36 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn knots() -> [[f32; 2]; 32] {
|
||||
std::array::from_fn(|i| {
|
||||
let x = 0.004 * 1.18f32.powi(i as i32);
|
||||
[x, 1.0 - (-2.0 * x).exp()]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chroma_curve_is_identity_unless_set_and_survives_serde() {
|
||||
let plain = CameraTone::new(knots()).unwrap();
|
||||
let map = ToneMap::camera(&plain, 0.0, 0.0, 0.0);
|
||||
assert!([0.0, 0.3, 0.77, 1.0, 2.0].iter().all(|o| map.chroma_scale(*o) == 1.0));
|
||||
assert!([0.0, 0.5, 1.0].iter().all(|o| ToneMap::new(0.0, 0.0, 0.0).chroma_scale(*o) == 1.0));
|
||||
// smart previews written before the chroma curve existed still load (identity)
|
||||
let old = serde_json::json!({ "knots": knots() });
|
||||
assert_eq!(serde_json::from_value::<CameraTone>(old).unwrap(), plain);
|
||||
let tone = plain.with_chroma([1.4, 1.3, 1.1, 1.0, 0.7, 0.4, 0.25, 0.2]).unwrap();
|
||||
let back: CameraTone = serde_json::from_value(serde_json::to_value(tone).unwrap()).unwrap();
|
||||
assert_eq!(back, tone);
|
||||
let map = ToneMap::camera(&tone, 0.0, 0.0, 0.0);
|
||||
assert!((map.chroma_scale(0.0) - 1.4).abs() < 1e-6 && (map.chroma_scale(1.0) - 0.2).abs() < 1e-6);
|
||||
assert!((map.chroma_scale(0.5 / 7.0) - 1.35).abs() < 1e-5, "interpolates between nodes");
|
||||
assert_eq!(map.chroma_scale(f32::NAN), 1.0);
|
||||
// hostile values are rejected, also when deserialized
|
||||
assert!(plain.with_chroma([f32::NAN; CHROMA_N]).is_none());
|
||||
assert!(plain.with_chroma([-1.0; CHROMA_N]).is_none());
|
||||
let bad = serde_json::json!({ "knots": knots(), "chroma": [9.0, 1, 1, 1, 1, 1, 1, 1] });
|
||||
assert!(serde_json::from_value::<CameraTone>(bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_curve_preserves_black_and_extends_headroom() {
|
||||
let knots = std::array::from_fn(|i| {
|
||||
|
||||
@@ -6,7 +6,11 @@ ARW decoding supplies a Bayer mosaic and camera white-balance multipliers, but c
|
||||
|
||||
A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 64-million-pixel input limit, converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. Near-black, clipped and nonfinite samples are excluded. At least 256 paired pixels, 5% coloured samples and a nonsingular input covariance with bounded condition are required.
|
||||
|
||||
Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity** correction (luminance-normalised RGB). This avoids trying to encode the camera's nonlinear tone curve in a matrix, which left skin grey in the earlier matrix-only estimate. Coefficients are bounded by 8. A matrix can't follow the camera's hue-dependent rendering, though: on a photo of a lime shirt among grey rocks the best matrix rendered the shirt olive (16° hue error), because the few saturated pixels have little weight against the many dull ones, and a matrix fitted to the shirt alone breaks everything else. So a **hue/saturation table** (72 hues × 5 saturations, the DNG `ProfileHueSatMap` layout, applied in linear ProPhoto RGB) is fitted to the residual hue shifts and saturation ratios after the matrix: Gaussian kernels around each node (2.5° hue, 0.15 saturation), weighted by saturation, shrunk toward identity where the photo has few samples, saturation 0 left as identity so neutrals stay neutral. The table restores each pixel's luminance (the tone curve owns luminance; without that, saturation boosts darkened colours and the table lost on held-out pixels). It is kept only when it lowers the held-out error after the tone curve is refitted; otherwise the matrix alone is used. Luminance is fitted separately: 32 equal-population bins with robust median knots and pooled-adjacent-violators isotonic regression. The curve preserves black, cannot reverse, and extends beyond observed highlights with a bounded shoulder.
|
||||
Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity** correction (luminance-normalised RGB). This avoids trying to encode the camera's nonlinear tone curve in a matrix, which left skin grey in the earlier matrix-only estimate. Coefficients are bounded by 8. A matrix can't follow the camera's hue-dependent rendering, though: on a photo of a lime shirt among grey rocks the best matrix rendered the shirt olive (16° hue error), because the few saturated pixels have little weight against the many dull ones, and a matrix fitted to the shirt alone breaks everything else. So a **hue/saturation table** (5 values × 72 hues × 5 saturations, the DNG `ProfileHueSatMap` layout with an sRGB-encoded value axis, applied in linear ProPhoto RGB after the baseline exposure) is fitted to the residual hue shifts and saturation ratios after the matrix: Gaussian kernels around each node (2.5° hue, 0.15 saturation, 0.12 encoded value), weighted by saturation, shrunk toward identity where the photo has few samples, saturation 0 left as identity so neutrals stay neutral. The value axis matters: on a sunny waterfall scene dark yellows had to turn ~12° toward orange while brighter yellow-greens turned ~9° toward green, and one shift per hue made both worse. The table restores each pixel's luminance (the tone curve owns luminance; without that, saturation boosts darkened colours and the table lost on held-out pixels).
|
||||
|
||||
The JPEG's colourfulness also depends on brightness, as a camera's per-channel tone curve makes it: relative to a luminance-only curve, shadows were up to 1.4× more colourful and highlights above 0.85 luminance bleached to 0.1–0.3×, so warm sunlit rock rendered cream instead of white (+4 b* in the brightest fifth of the picture). A **chroma curve** (8 nodes over display luminance, `CameraTone::chroma`) is fitted to that ratio, including the bright pixels the colour and tone fits leave out, and applied in the finish stage right after the tone map (CPU and GPU), scaling chroma toward luminance so luminance is kept. It is kept only when it lowers the held-out error; smart previews carry it in their header, and older headers load without one.
|
||||
|
||||
Matrix-only and matrix + table looks are each completed (tone curve, then chroma curve) and compared on the held-out pixels in gamma-encoded display values: in linear values a slightly missed bright rock outweighed a clearly wrong dark shirt. The acceptance gates below still use linear values. Luminance is fitted separately: 32 equal-population bins with robust median knots and pooled-adjacent-violators isotonic regression. The curve preserves black, cannot reverse, and extends beyond observed highlights with a bounded shoulder.
|
||||
|
||||
The matrix is applied to scene-linear sensor pixels. The curve is carried in `SourceInfo` and applied in the pipeline finish stage, after exposure/WB/local edits. It is never baked into decoded luminance or clipped to JPEG precision. CPU and GPU use the same tone LUT. Contrast, whites and blacks adjust the starting look through the display-domain controls. JPEG neutral rendering and the existing DNG colour model are unchanged.
|
||||
|
||||
@@ -26,7 +30,7 @@ Synthetic tests cover colour with a nonlinear camera tone, preserved scene-linea
|
||||
|
||||
Five local Sony ILCE-6700 ARWs were accepted. Held-out per-channel linear-display RMS values were 0.04244, 0.03349, 0.04552, 0.02871 and 0.03100. All five unedited supplied JPEG exports stayed pixel-identical to the installed pre-change renderer at equal size/quality. Four supplied JPEGs use Display P3 and two have a narrower crop than the RAW, so they are visual references rather than aligned pixel ground truth.
|
||||
|
||||
Public CC0 samples (raw.pixls.us; 10 files, ILCE-6000, -6400 ×2, -6700, -7M3, -7M4 ×2, -7RM4, -7SM3, DSC-RX100M3), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 7 fits accepted, ΔE 17–26 → 3–10 with matching mean lightness. (The RX100M3 was rejected at the time because the decoder read neither its white balance nor its black level and its render was green; since issue #148 its fit is accepted, held-out RMS 0.153 → 0.087.) Rejected and unchanged: an ILCE-7RM4 dusk scene whose fit was worse than the fallback (ΔE 7.6), and a reduced-size lossless ARW (no fit attempted). Saturated hues can still differ (an orange-red hull renders yellow). With the hue/saturation table, 28 local ILCE-7M4 ARWs (a month of family photos, one in 24 picked) went from mean ΔE 4.74 to 4.27 against their camera JPEGs (200 px, CIE76); 27 accepted a table, none got worse overall by more than 0.1, and a few saturated regions moved away (12.4 → 15.2 on one). The lime shirt's hue error dropped from 16° to 6–8°.
|
||||
Public CC0 samples (raw.pixls.us; 10 files, ILCE-6000, -6400 ×2, -6700, -7M3, -7M4 ×2, -7RM4, -7SM3, DSC-RX100M3), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 7 fits accepted, ΔE 17–26 → 3–10 with matching mean lightness. (The RX100M3 was rejected at the time because the decoder read neither its white balance nor its black level and its render was green; since issue #148 its fit is accepted, held-out RMS 0.153 → 0.087.) Rejected and unchanged: an ILCE-7RM4 dusk scene whose fit was worse than the fallback (ΔE 7.6), and a reduced-size lossless ARW (no fit attempted). Saturated hues can still differ (an orange-red hull renders yellow). With the hue/saturation table, 28 local ILCE-7M4 ARWs (a month of family photos, one in 24 picked) went from mean ΔE 4.74 to 4.27 against their camera JPEGs (200 px, CIE76); 27 accepted a table, none got worse overall by more than 0.1, and a few saturated regions moved away (12.4 → 15.2 on one). The lime shirt's hue error dropped from 16° to 6–8°. With the value axis, the chroma curve and gamma-domain selection, the same set plus the shirt photo (29 files) went from mean ΔE 4.23 to 3.54 (4.74 before the table), every file closer than before, saturated areas 7.65 → 6.41; all 29 now use a table. A small object stays hard: in a second shot of the lime shirt only 7 of the 6144 proxy pixels show it, so the table can't learn its colour and the shirt still renders yellower than the JPEG.
|
||||
|
||||
On one sample, Exposure +1 EV, Saturation +100, Contrast +50, warm/cool WB, tint, highlights and shadows all changed the output. The same matrix and curve were selected for 400-pixel and full-resolution renders. Headless UI checks wait until the loupe reports `source: render` and no jobs remain; the camera JPEG stand-in does not count as verification.
|
||||
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@ Ordered by user impact, then tier, then effort. The checklist above counts featu
|
||||
that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)).
|
||||
Take the first one nobody is working on.
|
||||
|
||||
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW now gets a guarded file-local fit to its own embedded JPEG (colour matrix + hue/saturation table + tone curve, relative WB; 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
|
||||
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW now gets a guarded file-local fit to its own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
|
||||
2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, RW2 v4, Nikon
|
||||
"lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see
|
||||
`crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`.
|
||||
@@ -274,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
|
||||
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + hue/saturation table + tone curve, relative WB, kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB, kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
|
||||
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
|
||||
|
||||
Reference in New Issue
Block a user