mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-13 17:31:09 -06:00
LR-BEHAV-RENDER-FIDELITY: process V5 rolls raw tone curves off into white in stops, so raising Exposure keeps highlights (#1632) (#3783)
* LR-BEHAV-RENDER-FIDELITY: V3 camera tone curve rolls off in stops past its last knot (#1632) A raw's camera tone curve is fitted only to camera JPEG tones below 0.85, so its last knot sits 1 to 3 stops below the sensor's white. Past it, CameraTone::apply closes on white exponentially in linear light: the sensor's white renders at 8-bit 246-254 at Exposure 0 and 254-255 from +1, and raising Exposure turns the top stop the sensor recorded white. Process V3 extends the curve with CameraTone::apply_rolloff: same value and slope at the knot, then gap / (1 + c (2^t - 1)) at t stops past it, the gap halving every stop in the end. On 11 CC0 corpus files with a fitted look, unclipped pixels rendering white drop from up to 22.5% to 0 at +1 EV and from up to 35% to 0 at +2 EV; Exposure 0 renders change by at most 1 code on average. V1 and V2, the fit itself, JPEGs and raws without a fitted look are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * LR-BEHAV-RENDER-FIDELITY: V3 generic raw curve rolls off in stops too; DRO-off, DNG and GPU checks (#1632) Raws without a camera curve take ToneMap::hdr, which reached white about 1/4 stop past the sensor's white and clipped there (o.min(1.0)). Process V3 now uses ToneMap::rolloff_hdr: from half a stop below the white point (tone::GENERIC_KNEE_STOPS) the extended Reinhard curve hands over to the same stop-based shoulder as the camera curve (tone::rolloff_past, shared with CameraTone::apply_rolloff), with the same value and slope at the join. The sensor's white renders at about 235 / 248 / 252 at Exposure 0 / +1 / +2 where V1 and V2 render 239 / 255 / 255. On 6 CC0 DNGs on the generic curve, unclipped pixels rendering white drop from up to 8.4% to 0 at +1 EV and from up to 17.2% to 0 at +2 EV. The DRO-off gate (look_fit::at_most) also checks the V3 shoulder; the rate bound already implies it, so no fit changes and LOOK_VERSION stays. Tests: the generic shoulder (join, slope, monotone, below white, no kink), a regression test that fails with the clipping curve, a DNG ProfileToneCurve under V3, the DRO-off property tests on the V3 shoulder, and a per-process camera-curve GPU check. Docs: process-versions, parity checklist (LR-BEHAV-RENDER-FIDELITY, LRC-DEV-PROCESS), gap 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * LR-BEHAV-RENDER-FIDELITY: what's new: process 3 keeps raw highlights when Exposure is raised (#1632) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * LR-BEHAV-RENDER-FIDELITY: a flat last camera-curve segment joins the V3 shoulder with its own slope (#1632) The V3 camera shoulder took the last segment's slope with a floor of 0.1, so a flat or shallow segment (valid for a fitted, DNG or saved curve) joined with a kink: slope 0 on the curve, about 0.1 just past it. The shoulder now keeps the segment's own slope at the knot. Below 0.1 a quadratic term in 2^t - 1 joins the denominator: it adds no slope at the knot and makes up the 0.1 pace further up, so a flat curve still closes on white. Segments at 0.1 or steeper are byte-identical, and the generic curve passes no floor. Comments now say the shoulder approaches white in real numbers and can round to it in f32. Found in an independent review of the branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * LR-BEHAV-RENDER-FIDELITY: V4's GPU test pins V4; the corpus DRO photos keep their knots under the V5 bound (#1632) The GPU light-operator test assumed the default process is V4; it now sets V4, since V5 is the default. The corpus DRO test checks that dro_off's V5 bound leaves the second-last knot where the rate bound puts it on the Sony DRO samples, so their V1 to V4 renders don't change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * LR-BEHAV-RENDER-FIDELITY: the DRO-off curve is main's again, so V1 to V4 keep it (#1632) CodeRabbit found that the V5 bound in `dro_off` could move the second-last knot of a DRO photo whose fitted last segment is flat, and V1 to V4 read that curve too. `dro_off` and `at_most` are back to main's, so the camera-look fit is unchanged and LOOK_VERSION stays. The DRO tests check the V5 shoulder where the lowered segment's slope over its squared gap is at most the fitted one's (then it stays below), and on the Sony DRO corpus samples. Also: `rolloff_hdr`'s doc names the HDR lift, and the release note names DNGs whose curve already reaches white. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5981ecadbb
commit
baf079f659
@@ -90,12 +90,15 @@ pub enum Process {
|
||||
/// camera curve keeps V2 Whites and fitted pointwise operators follow it
|
||||
/// (`lightcraft_pipeline::light_v3`, `docs/light-v3.md`).
|
||||
V4,
|
||||
/// V4, with a raw's tone curve rolling off into white in stops past its last fitted knot, so
|
||||
/// highlights the sensor recorded keep their detail when Exposure lifts them (issue #1632).
|
||||
V5,
|
||||
}
|
||||
|
||||
impl Process {
|
||||
/// Every process, oldest first.
|
||||
pub const ALL: [Process; 4] = [Process::V1, Process::V2, Process::V3, Process::V4];
|
||||
pub const LATEST: Process = Process::V4;
|
||||
pub const ALL: [Process; 5] = [Process::V1, Process::V2, Process::V3, Process::V4, Process::V5];
|
||||
pub const LATEST: Process = Process::V5;
|
||||
|
||||
pub const fn version(self) -> ProcessVersion {
|
||||
match self {
|
||||
@@ -103,6 +106,7 @@ impl Process {
|
||||
Process::V2 => ProcessVersion(2),
|
||||
Process::V3 => ProcessVersion(3),
|
||||
Process::V4 => ProcessVersion(4),
|
||||
Process::V5 => ProcessVersion(5),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,12 @@ pub(super) fn dro_off(own_tone: &CameraTone) -> CameraTone {
|
||||
// Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots
|
||||
// (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the
|
||||
// extended highlights would end up above the fitted curve. Raise the second-last knot (never
|
||||
// above the fitted curve) until the rate is at most the fitted curve's.
|
||||
// above the fitted curve) until the rate is at most the fitted curve's. Process V5's shoulder
|
||||
// (`CameraTone::apply_rolloff`) is not part of this bound or of `at_most`: V1 to V4 read the
|
||||
// same curve, and a V5 bound here would change their renders. It stays below the fitted
|
||||
// curve's too when the last segment's slope over the squared gap to white is at most the fitted
|
||||
// curve's; a lowered curve with a nearly flat last segment can end up above it far past the last
|
||||
// knot under V5 (issue #1632).
|
||||
let ([a, b], last) = ([own[30], own[31]], knots[31]);
|
||||
let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01);
|
||||
let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]);
|
||||
|
||||
@@ -1285,6 +1285,30 @@ fn assert_lowered(fitted: &CameraLook, fixed: &CameraLook, what: &str) {
|
||||
let x = 0.001 * 1.005f32.powi(i);
|
||||
assert!(fixed.tone.apply(x) <= fitted.tone.apply(x) + 1e-5, "{what} at {x}: {} > {}", fixed.tone.apply(x), fitted.tone.apply(x));
|
||||
}
|
||||
// V5's shoulder stays below too whenever the lowered last segment's slope over its squared gap to
|
||||
// white is at most the fitted one's (`dro_off` doesn't enforce it, so V1 to V4 keep their curve;
|
||||
// issue #1632)
|
||||
if v5_bounded(&fixed.tone, &fitted.tone) {
|
||||
assert!(v5_below(&fixed.tone, &fitted.tone), "{what}: brighter than the fitted curve under V5");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the last segment of `a`, over its squared gap to white, is at most `b`'s: then `a`'s V5
|
||||
/// shoulder ([`CameraTone::apply_rolloff`]) is nowhere above `b`'s (issue #1632).
|
||||
fn v5_bounded(a: &CameraTone, b: &CameraTone) -> bool {
|
||||
let rate = |t: &CameraTone| {
|
||||
let k = t.knots();
|
||||
((k[31][1] - k[30][1]) / (k[31][0] - k[30][0])).max(0.0) / (1.0 - k[31][1]).powi(2)
|
||||
};
|
||||
rate(a) <= rate(b)
|
||||
}
|
||||
|
||||
/// Whether `a`'s V5 curve is nowhere brighter than `b`'s, from deep shadows to far past the last knot.
|
||||
fn v5_below(a: &CameraTone, b: &CameraTone) -> bool {
|
||||
(0..=2400).all(|i| {
|
||||
let x = 0.001 * 1.005f32.powi(i);
|
||||
a.apply_rolloff(x) <= b.apply_rolloff(x) + 1e-5
|
||||
})
|
||||
}
|
||||
|
||||
/// Issue #244: Sony's Dynamic Range Optimizer lifts the camera JPEG's shadows, not the raw's. A
|
||||
@@ -1423,6 +1447,8 @@ fn corpus_arw_with_dro_is_lowered_to_the_dro_off_tone() {
|
||||
let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap();
|
||||
if dro {
|
||||
assert_lowered(&fitted, &look, name);
|
||||
// the Sony DRO samples' lowered curves stay below their fitted ones under V5 as well
|
||||
assert!(v5_below(&look.tone, &fitted.tone), "{name}: brighter than the fitted curve under V5");
|
||||
for x in lowered {
|
||||
assert!(look.tone.apply(x) < 0.95 * fitted.tone.apply(x), "{name} at {x}: {} vs {}", look.tone.apply(x), fitted.tone.apply(x));
|
||||
}
|
||||
|
||||
@@ -1844,3 +1844,35 @@ fn existing_photo_settings_are_not_rewritten() {
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #1632: a DNG's `ProfileToneCurve` is the finish stage's camera curve, so process V5 extends
|
||||
/// it past its last point (the sensor's white, scene 1.0) with the stop roll-off as well. A curve
|
||||
/// that reaches white there renders as under V4 up to it and white past it; one that ends below
|
||||
/// white (here 0.9) keeps what Exposure lifts past the sensor's white apart instead of reaching
|
||||
/// 8-bit 255 within a stop.
|
||||
#[test]
|
||||
fn v5_extends_a_dng_tone_curve_past_white_in_stops() {
|
||||
use lightcraft_color::transfer::linear_to_srgb;
|
||||
use lightcraft_develop::{DevelopSettings, Process};
|
||||
use lightcraft_pipeline::SourceInfo;
|
||||
let code = |curve: &[f64], process: Process, y: f32| {
|
||||
let tone = lightcraft_raw::profile::ToneCurve::from_tag(curve).and_then(|c| super::dng_tone_curve(&c));
|
||||
let info = SourceInfo { raw: true, camera_tone: Some(tone.expect("a valid curve")), ..SourceInfo::default() };
|
||||
let s = DevelopSettings { process: process.version(), ..DevelopSettings::default() };
|
||||
let o = lightcraft_pipeline::finish::base_tone(&s, &info, 1.0).apply(y);
|
||||
(linear_to_srgb(o.clamp(0.0, 1.0)) * 255.0).round()
|
||||
};
|
||||
let to_white = [0.0, 0.0, 0.03, 0.09, 0.18, 0.4, 0.5, 0.78, 1.0, 1.0];
|
||||
for y in [0.01, 0.05, 0.18, 0.5, 0.9] {
|
||||
assert_eq!(code(&to_white, Process::V5, y), code(&to_white, Process::V4, y), "{y}");
|
||||
}
|
||||
for y in [1.0, 2.0, 4.0] {
|
||||
assert_eq!((code(&to_white, Process::V4, y), code(&to_white, Process::V5, y)), (255.0, 255.0), "{y}");
|
||||
}
|
||||
let below_white = [0.0, 0.0, 0.03, 0.09, 0.18, 0.4, 0.5, 0.72, 1.0, 0.9];
|
||||
let at = |process, ev: f32| code(&below_white, process, ev.exp2());
|
||||
assert_eq!(at(Process::V5, 0.0), at(Process::V4, 0.0));
|
||||
assert_eq!(at(Process::V4, 1.0), 255.0, "V4 is frozen");
|
||||
let (white, beyond) = (at(Process::V5, 0.0), at(Process::V5, 1.0));
|
||||
assert!(beyond < 254.0 && beyond - white >= 3.0, "{white} then {beyond} a stop past the sensor's white");
|
||||
}
|
||||
|
||||
@@ -92,6 +92,12 @@ 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));
|
||||
// each process's shoulder past the last knot, with highlights lifted into it (issue #1632)
|
||||
for p in Process::ALL {
|
||||
let mut lifted = DevelopSettings { process: p.version(), ..DevelopSettings::default() };
|
||||
lifted.light.exposure = 2.0;
|
||||
check(&format!("camera tone {p:?} exposure +2"), &src, &info, &lifted, &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 };
|
||||
@@ -709,8 +715,7 @@ fn light_v3_operators_match() {
|
||||
let req = RenderRequest::fit(720, 720);
|
||||
for id in ["light.contrast", "light.whites", "light.blacks"] {
|
||||
for v in [-100.0, -50.0, 50.0, 100.0] {
|
||||
let mut s = DevelopSettings::default();
|
||||
assert_eq!(s.process.process(), Process::V4);
|
||||
let mut s = DevelopSettings { process: Process::V4.version(), ..Default::default() };
|
||||
lightcraft_develop::controls::set(&mut s, id, v);
|
||||
check(&format!("V4 {id} {v}"), &src, &info, &s, &req);
|
||||
if id == "light.whites" {
|
||||
@@ -723,7 +728,7 @@ fn light_v3_operators_match() {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = DevelopSettings::default();
|
||||
let mut s = DevelopSettings { process: Process::V4.version(), ..Default::default() };
|
||||
typical(&mut s);
|
||||
s.light.contrast = 35.0;
|
||||
s.light.whites = 40.0;
|
||||
|
||||
@@ -347,6 +347,13 @@ pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap {
|
||||
None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak),
|
||||
None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak),
|
||||
},
|
||||
// V4, with a raw's tone curve rolling off into white in stops: a camera curve past its last
|
||||
// knot, the generic curve from half a stop below its white point instead of clipping (issue #1632)
|
||||
Process::V5 => match info.camera_tone.as_ref().filter(|_| info.raw) {
|
||||
Some(curve) => ToneMap::camera_rolloff_hdr(curve, 0.0, l.whites, 0.0, peak),
|
||||
None if info.raw => ToneMap::rolloff_hdr(l.contrast, l.whites, l.blacks, peak),
|
||||
None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -278,13 +278,13 @@ pub struct LightOps {
|
||||
/// The fits of Contrast and Blacks, in the order they apply.
|
||||
pub const FITS: [&Fit; 2] = [&fitted::CONTRAST, &fitted::BLACKS];
|
||||
|
||||
/// Whether `s` renders `info` with the V4 operators: process V4 and a raw with its camera's tone
|
||||
/// Whether `s` renders `info` with the V4 operators: process V4 or later and a raw with its camera's tone
|
||||
/// curve (other sources keep the tone map's own Contrast and Blacks).
|
||||
pub fn applies(s: &DevelopSettings, info: &SourceInfo) -> bool {
|
||||
let camera_look = info.raw && info.camera_tone.is_some();
|
||||
match s.process.process() {
|
||||
Process::V1 | Process::V2 | Process::V3 => false,
|
||||
Process::V4 => camera_look,
|
||||
Process::V4 | Process::V5 => camera_look,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -375,7 +375,7 @@ pub fn base_detail_radius(px_per_long: f64) -> usize {
|
||||
pub fn tone_base_detail(s: &DevelopSettings, px_per_long: f64) -> usize {
|
||||
match s.process.process() {
|
||||
Process::V1 => 0,
|
||||
Process::V2 | Process::V3 | Process::V4 => base_detail_radius(px_per_long),
|
||||
Process::V2 | Process::V3 | Process::V4 | Process::V5 => base_detail_radius(px_per_long),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ fn v3_sliders_move_a_grey_ramp_their_way() {
|
||||
let ramp = Rgb32f::from_fn(256, 8, |x, _| [0.0005 * 1.035f32.powi(x as i32); 3]);
|
||||
let shot = |id: &str, v: f64| {
|
||||
let mut s = DevelopSettings::default();
|
||||
assert_eq!(s.process.process(), Process::V4);
|
||||
assert!(s.process.process() >= Process::V4, "the default process has the V4 sliders");
|
||||
controls::set(&mut s, id, v);
|
||||
let r = render(&ramp, &camera_look(), &s, &RenderRequest::fit(256, 8)).image;
|
||||
(0..256).map(|x| r.get(x, 4)).collect::<Vec<[u8; 4]>>()
|
||||
|
||||
@@ -264,3 +264,67 @@ fn the_base_plane_is_cached_per_process() {
|
||||
assert_ne!(local::base_key(&s1, sg, 0), local::base_key(&s2, sg, 0), "the process is part of the key");
|
||||
assert_ne!(local::base_key(&s2, sg, 0), local::base_key(&s2, sg, sig.base_detail));
|
||||
}
|
||||
|
||||
/// Issue #1632: past its last fitted knot (here 1.0 stop below the sensor's white, scene 1.0, at
|
||||
/// 8-bit 233, as on the CC0 ILCE-7CR file) a raw's camera tone curve closed on white exponentially
|
||||
/// in linear light, so Exposure +1 rendered the sensor's white at 255 and Exposure +2 left the top
|
||||
/// stop it recorded (scene 0.5..1.0) under one 8-bit code. The latest process rolls off in stops
|
||||
/// and keeps both apart; V1 to V4 keep their look.
|
||||
#[test]
|
||||
fn exposure_keeps_the_highlights_a_camera_curve_recorded() {
|
||||
use lightcraft_color::transfer::linear_to_srgb;
|
||||
let knots = std::array::from_fn(|i| {
|
||||
let x = 0.003 * 1.18f32.powi(i as i32);
|
||||
[x, 1.0 - (-3.3 * x).exp()]
|
||||
});
|
||||
let info = SourceInfo { raw: true, camera_tone: crate::tone::CameraTone::new(knots), ..SourceInfo::default() };
|
||||
let at = |process: Process, ev: f32, y: f32| {
|
||||
let o = crate::finish::base_tone(&on(process), &info, 1.0).apply(y * ev.exp2());
|
||||
(linear_to_srgb(o.clamp(0.0, 1.0)) * 255.0).round()
|
||||
};
|
||||
for ev in [0.0, 1.0, 2.0] {
|
||||
let (white, half) = (at(Process::LATEST, ev, 1.0), at(Process::LATEST, ev, 0.5));
|
||||
assert!(white <= 254.0, "Exposure {ev:+}: the sensor's white renders at {white}");
|
||||
assert!(white - half >= 1.5, "Exposure {ev:+}: the top stop spans {half}..{white}");
|
||||
}
|
||||
// the earlier processes are frozen: the sensor's white at 255 from Exposure +1
|
||||
for p in [Process::V1, Process::V2, Process::V3, Process::V4] {
|
||||
assert_eq!(at(p, 1.0, 1.0), 255.0, "{p:?}");
|
||||
}
|
||||
// up to the last knot every process renders the camera's curve
|
||||
for y in [0.003, 0.02, 0.18, 0.4, 0.5] {
|
||||
let (v2, v3) = (crate::finish::base_tone(&on(Process::V2), &info, 1.0), crate::finish::base_tone(&on(Process::LATEST), &info, 1.0));
|
||||
assert!((v2.apply(y) - v3.apply(y)).abs() < 1e-6, "{y}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #1632: a raw without a camera curve takes the generic curve, which reached white about ¼
|
||||
/// stop past the sensor's white (scene 1.0) and clipped there, so Exposure +1 rendered the top stop
|
||||
/// the sensor recorded (scene 0.5..1.0) at 8-bit 255 from its upper half. The latest process rolls
|
||||
/// off in stops from half a stop below the white point; V1 to V4 keep their look.
|
||||
#[test]
|
||||
fn exposure_keeps_the_highlights_the_generic_curve_recorded() {
|
||||
use lightcraft_color::transfer::linear_to_srgb;
|
||||
let info = SourceInfo { raw: true, ..SourceInfo::default() };
|
||||
let at = |process: Process, ev: f32, y: f32| {
|
||||
let o = crate::finish::base_tone(&on(process), &info, 1.0).apply(y * ev.exp2());
|
||||
(linear_to_srgb(o.clamp(0.0, 1.0)) * 255.0).round()
|
||||
};
|
||||
for ev in [0.0, 1.0, 2.0] {
|
||||
let (white, half) = (at(Process::LATEST, ev, 1.0), at(Process::LATEST, ev, 0.5));
|
||||
assert!(white <= 253.0, "Exposure {ev:+}: the sensor's white renders at {white}");
|
||||
assert!(white - half >= 3.0, "Exposure {ev:+}: the top stop spans {half}..{white}");
|
||||
}
|
||||
// the curve still reaches white, 5 stops past the sensor's
|
||||
assert_eq!(at(Process::LATEST, 5.0, 1.0), 255.0);
|
||||
// the earlier processes are frozen: the sensor's white at 255 from Exposure +1
|
||||
for p in [Process::V1, Process::V2, Process::V3, Process::V4] {
|
||||
assert_eq!(at(p, 1.0, 1.0), 255.0, "{p:?}");
|
||||
}
|
||||
assert_eq!(at(Process::V2, 0.0, 1.0), at(Process::V1, 0.0, 1.0));
|
||||
// below the knee every process renders the same curve
|
||||
let (v2, v3) = (crate::finish::base_tone(&on(Process::V2), &info, 1.0), crate::finish::base_tone(&on(Process::LATEST), &info, 1.0));
|
||||
for y in [0.003, 0.02, 0.18, 0.4, 0.6] {
|
||||
assert_eq!(v2.apply(y), v3.apply(y), "{y}");
|
||||
}
|
||||
}
|
||||
|
||||
+233
-4
@@ -90,8 +90,88 @@ impl CameraTone {
|
||||
let slope = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0);
|
||||
1.0 - (1.0 - b[1]) * (-(y - b[0]) * slope / (1.0 - b[1]).max(0.01)).exp()
|
||||
}
|
||||
|
||||
/// [`CameraTone::apply`] as process V5 extends it past the last knot (issue #1632). The curve is
|
||||
/// fitted only to camera JPEG tones below 0.85, so its last knot lies 1 to 3 stops below the
|
||||
/// sensor's white on most photos and everything above it is extrapolated. [`CameraTone::apply`]
|
||||
/// closes the gap to white exponentially in linear light: it reaches 8-bit 255 within about a
|
||||
/// stop past the sensor's white, and Exposure +1 turns the top stop the sensor recorded white.
|
||||
/// Here the gap shrinks in stops instead, `gap / (1 + c·(2^(k·t) − 1))` at `t` stops past the
|
||||
/// knot, with `c` matching the curve's slope there (so the join is smooth) and the gap halving
|
||||
/// every [`ROLLOFF_STOPS`] stops in the end. Same value as [`CameraTone::apply`] up to the last
|
||||
/// knot, monotone, finite and at most 1. In real numbers it only approaches white; in `f32` the
|
||||
/// gap can round to 0 far up, and within a stop or two for a last knot a few ulps below white.
|
||||
pub fn apply_rolloff(&self, y: f32) -> f32 {
|
||||
let (a, b) = (self.knots[30], self.knots[31]);
|
||||
if !y.is_finite() || y <= b[0] {
|
||||
return self.apply(y);
|
||||
}
|
||||
// the last segment's own slope, so the join is smooth even when it is flat (`new` keeps the
|
||||
// knots non-decreasing, so it is at least 0)
|
||||
let slope = ((b[1] - a[1]) / (b[0] - a[0])).max(0.0);
|
||||
// `CameraTone::new` keeps the last knot below 1, so the gap is positive (no floor: one
|
||||
// would step the curve down past a knot within it of white)
|
||||
rolloff_past(y, b, slope, CAMERA_MIN_SLOPE)
|
||||
}
|
||||
}
|
||||
|
||||
/// The pace [`CameraTone::apply_rolloff`] closes on white at least at, as display per unit of scene
|
||||
/// luminance at the last knot (the floor [`CameraTone::apply`] puts on its slope there). A last
|
||||
/// segment flatter than this still joins with its own slope; [`rolloff_past`] then adds a term that
|
||||
/// has no slope at the knot and reaches this pace further up, so a flat curve does not stay below
|
||||
/// white.
|
||||
const CAMERA_MIN_SLOPE: f32 = 0.1;
|
||||
|
||||
/// Stops in which the gap between a V5 curve and white halves, far past where its shoulder starts
|
||||
/// ([`CameraTone::apply_rolloff`], [`ToneMap::rolloff_hdr`]). With 1, the sensor's white renders at
|
||||
/// 8-bit 239–250 on the CC0 corpus files with a camera curve (Sony, Nikon, Fujifilm) at Exposure 0
|
||||
/// and below 254 up to Exposure +2, where the extension of [`CameraTone::apply`] reaches 254–255 by
|
||||
/// Exposure +1 (issue #1632).
|
||||
pub const ROLLOFF_STOPS: f32 = 1.0;
|
||||
|
||||
/// Process V5's shoulder past a knee at scene luminance `x` and display `o` (below 1), where the
|
||||
/// curve rises at `slope` (display per unit of scene luminance): the gap to white is
|
||||
/// `gap / (1 + c·(2^(k·t) − 1))` at `t = log2(y / x)` stops past the knee, `k = 1 / ROLLOFF_STOPS`,
|
||||
/// so it halves every [`ROLLOFF_STOPS`] stops in the end. `c` gives it the curve's slope at the
|
||||
/// knee: d(display)/d(stop) there is `slope · x · ln 2` on the curve and `gap · c · k · ln 2` on the
|
||||
/// shoulder. When `slope` is below `min_slope`, `d · (u − ln(1 + u))` (with `u = 2^(k·t) − 1`)
|
||||
/// joins the denominator: it adds no slope at the knee, so the join keeps the curve's own slope, and
|
||||
/// far up it grows like `d · u`, so the shoulder closes on white at `min_slope`'s pace (the gap still
|
||||
/// halves every [`ROLLOFF_STOPS`] stops in the end). A term growing faster than `u` would close a
|
||||
/// flat curve on white faster than a steeper one far up, and a darker curve would end up above it.
|
||||
/// Monotone for `y ≥ x`, finite and at most 1; in real numbers it only approaches white, while in
|
||||
/// `f32` the gap rounds to 0 eventually.
|
||||
fn rolloff_past(y: f32, [x, o]: [f32; 2], slope: f32, min_slope: f32) -> f32 {
|
||||
let gap = 1.0 - o;
|
||||
let k = 1.0 / ROLLOFF_STOPS;
|
||||
// bounded, so `c · 0` right past the knee stays 0 for a gap of a few ulps
|
||||
let per = |s: f32| (s * x / (gap * k)).min(1e30);
|
||||
let c = per(slope);
|
||||
let d = (per(min_slope) - c).max(0.0);
|
||||
let u = (k * (y / x).log2()).exp2() - 1.0;
|
||||
// each term only when its coefficient is positive: `0 · ∞` is NaN far up
|
||||
let linear = if c > 0.0 { c * u } else { 0.0 };
|
||||
let floor = if d > 0.0 { d * past_join(u) } else { 0.0 };
|
||||
1.0 - gap / (1.0 + linear + floor)
|
||||
}
|
||||
|
||||
/// `u − ln(1 + u)` for `u ≥ 0`: 0 with no slope at 0, then rising like `u` (infinite for infinite
|
||||
/// `u`). In `f64`, where the difference keeps its digits down to tiny `u`.
|
||||
fn past_join(u: f32) -> f32 {
|
||||
if u.is_infinite() {
|
||||
return u;
|
||||
}
|
||||
let u = f64::from(u);
|
||||
(u - u.ln_1p()).max(0.0) as f32
|
||||
}
|
||||
|
||||
/// Stops below its white point where process V5's generic raw curve ([`ToneMap::rolloff_hdr`])
|
||||
/// leaves the extended Reinhard curve for the [`ROLLOFF_STOPS`] shoulder, instead of clipping at
|
||||
/// the white point. With ½, the sensor's white of a raw without a camera curve (scene 1.0, about
|
||||
/// ¼ stop below that white point at default settings) renders at about 8-bit 235, 248 and 252 at
|
||||
/// Exposure 0, +1 and +2 where V1 to V4 render 239, 255 and 255 (issue #1632).
|
||||
pub const GENERIC_KNEE_STOPS: f32 = 0.5;
|
||||
|
||||
/// Scene EV over grey where [`ToneMap::hdr`] starts lifting highlights above the SDR curve.
|
||||
const HDR_FROM_EV: f32 = 1.5;
|
||||
|
||||
@@ -111,6 +191,16 @@ impl ToneMap {
|
||||
/// gain map only lifts highlights), then the highlights gain up to `peak − 1` more as in
|
||||
/// [`ToneMap::hdr`].
|
||||
pub fn camera_hdr(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap {
|
||||
ToneMap::camera_with(|y| curve.apply(y), curve.chroma, contrast, whites, blacks, peak)
|
||||
}
|
||||
|
||||
/// [`ToneMap::camera_hdr`] of process V5: past its last knot the camera curve rolls off in stops
|
||||
/// ([`CameraTone::apply_rolloff`]) instead of closing on white within a stop of the sensor's.
|
||||
pub fn camera_rolloff_hdr(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap {
|
||||
ToneMap::camera_with(|y| curve.apply_rolloff(y), curve.chroma, contrast, whites, blacks, peak)
|
||||
}
|
||||
|
||||
fn camera_with(curve: impl Fn(f32) -> f32, chroma: [f32; CHROMA_N], contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap {
|
||||
let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 };
|
||||
let adjustment = Self::display(contrast, whites, blacks);
|
||||
let neutral = contrast == 0.0 && whites == 0.0 && blacks == 0.0;
|
||||
@@ -118,12 +208,12 @@ impl ToneMap {
|
||||
let lut = (0..LUT_N)
|
||||
.map(|i| {
|
||||
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
|
||||
let y = curve.apply(GREY * 2f32.powf(ev));
|
||||
let y = curve(GREY * 2f32.powf(ev));
|
||||
let o = if neutral { y } else { adjustment.apply(y) };
|
||||
if peak > 1.0 { (o + (peak - 1.0) * smooth(HDR_FROM_EV, top, ev)).clamp(0.0, peak) } else { o }
|
||||
})
|
||||
.collect();
|
||||
ToneMap { lut, chroma: curve.chroma }
|
||||
ToneMap { lut, chroma }
|
||||
}
|
||||
/// `contrast`, `whites`, `blacks` in −100..100 (Lightroom slider units).
|
||||
pub fn new(contrast: f64, whites: f64, blacks: f64) -> ToneMap {
|
||||
@@ -136,6 +226,19 @@ impl ToneMap {
|
||||
/// white point moved up by the headroom, so the result stays monotone, never exceeds `peak`,
|
||||
/// and reaches it there (within the table's range).
|
||||
pub fn hdr(contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap {
|
||||
ToneMap::generic(contrast, whites, blacks, peak, false)
|
||||
}
|
||||
|
||||
/// [`ToneMap::hdr`] of process V5 (issue #1632): from [`GENERIC_KNEE_STOPS`] below the white
|
||||
/// point the curve rolls off into white in stops, with the same value and slope at the join,
|
||||
/// instead of reaching white about ¼ stop past the sensor's white and clipping there. The SDR
|
||||
/// curve stays below 1 (SDR white) and is the same as [`ToneMap::hdr`]'s below the join; for
|
||||
/// `peak` above 1 the HDR lift then takes highlights up to `peak`, as in [`ToneMap::hdr`].
|
||||
pub fn rolloff_hdr(contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap {
|
||||
ToneMap::generic(contrast, whites, blacks, peak, true)
|
||||
}
|
||||
|
||||
fn generic(contrast: f64, whites: f64, blacks: f64, peak: f32, rolloff: bool) -> ToneMap {
|
||||
let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 };
|
||||
let c = (contrast / 100.0) as f32;
|
||||
let slope = if c >= 0.0 { 1.0 + 0.55 * c } else { 1.0 + 0.4 * c };
|
||||
@@ -144,12 +247,17 @@ impl ToneMap {
|
||||
let wl = GREY * 2f32.powf(white_ev);
|
||||
let pre = 1.0 + GREY / wl; // keep grey near grey
|
||||
let b = (blacks / 100.0) as f32;
|
||||
// extended Reinhard with white point wl: y(1 + y/wl²)/(1 + y), reaching 1 at wl
|
||||
let reinhard = |y: f32| y * (1.0 + y / (wl * wl)) / (1.0 + y);
|
||||
// V5's knee, below the white point, and the curve's slope there: (1 + y(2 + y)/wl²)/(1 + y)²
|
||||
let knee = wl * (-GENERIC_KNEE_STOPS).exp2();
|
||||
let knee_slope = (1.0 + knee * (2.0 + knee) / (wl * wl)) / ((1.0 + knee) * (1.0 + knee));
|
||||
let knee = [knee, reinhard(knee)];
|
||||
let lut = (0..LUT_N)
|
||||
.map(|i| {
|
||||
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
|
||||
let y = GREY * 2f32.powf(ev * slope) * pre;
|
||||
// extended Reinhard with white point wl: y(1 + y/wl²)/(1 + y)
|
||||
let mut o = y * (1.0 + y / (wl * wl)) / (1.0 + y);
|
||||
let mut o = if rolloff && y > knee[0] && knee[1] < 1.0 { rolloff_past(y, knee, knee_slope, 0.0) } else { reinhard(y) };
|
||||
o = o.min(1.0);
|
||||
// Toe: blacks < 0 crushes, > 0 lifts.
|
||||
if b < 0.0 {
|
||||
@@ -324,6 +432,127 @@ mod tests {
|
||||
assert!(serde_json::from_value::<CameraTone>(serde_json::json!({"knots": invalid})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolloff_joins_the_curve_smoothly_and_never_reaches_white() {
|
||||
let curve = CameraTone::new(knots()).unwrap();
|
||||
let last = curve.knots()[31];
|
||||
// the camera's curve up to the last knot
|
||||
for i in 0..200 {
|
||||
let y = last[0] * i as f32 / 199.0;
|
||||
assert_eq!(curve.apply_rolloff(y), curve.apply(y), "{y}");
|
||||
}
|
||||
// continuous, with the slope of the last segment, at the knot
|
||||
let a = curve.knots()[30];
|
||||
let slope = (last[1] - a[1]) / (last[0] - a[0]);
|
||||
let h = last[0] * 1e-3;
|
||||
let after = (curve.apply_rolloff(last[0] + h) - curve.apply_rolloff(last[0])) / h;
|
||||
assert!((after - slope).abs() < 0.02 * slope, "{after} vs {slope}");
|
||||
// monotone, below white, and slower to close on it than the V1/V2 extension
|
||||
let mut previous = last[1];
|
||||
for i in 1..3000 {
|
||||
let y = last[0] * 1.005f32.powi(i);
|
||||
let o = curve.apply_rolloff(y);
|
||||
assert!(o >= previous && o < 1.0, "{y}: {o}");
|
||||
assert!(o <= curve.apply(y) + 1e-6, "{y}");
|
||||
previous = o;
|
||||
}
|
||||
assert!(1.0 - curve.apply_rolloff(last[0] * 8.0) > 4.0 * (1.0 - curve.apply(last[0] * 8.0)));
|
||||
// a steep last segment joins with its own slope, and a last knot next to white without a step
|
||||
for top in [0.9, 0.99995, 1.0 - f32::EPSILON] {
|
||||
let mut k = knots();
|
||||
k[31] = [k[30][0] * 1.01, top];
|
||||
let c = CameraTone::new(k).unwrap();
|
||||
let slope = (k[31][1] - k[30][1]) / (k[31][0] - k[30][0]);
|
||||
assert!(slope > 16.0 || top < 0.99, "{slope}");
|
||||
let mut prev = c.apply_rolloff(k[31][0]);
|
||||
assert_eq!(prev, top);
|
||||
for i in 1..2000 {
|
||||
let o = c.apply_rolloff(k[31][0] * 1.0001f32.powi(i));
|
||||
assert!(o.is_finite() && o >= prev && o <= 1.0, "{top} {i}: {o} < {prev}");
|
||||
prev = o;
|
||||
}
|
||||
if top == 0.9 {
|
||||
let h = k[31][0] * 1e-5;
|
||||
let after = (c.apply_rolloff(k[31][0] + h) - top) / h;
|
||||
assert!((after - slope).abs() < 0.02 * slope, "{after} vs {slope}");
|
||||
}
|
||||
}
|
||||
// a flat or shallow last segment joins with its own slope (not the 0.1 floor of the V1/V2
|
||||
// extension) and still closes on white further up
|
||||
for s in [0.0f32, 0.05] {
|
||||
let mut k = knots();
|
||||
k[31][1] = k[30][1] + s * (k[31][0] - k[30][0]);
|
||||
let c = CameraTone::new(k).unwrap();
|
||||
let (x, top) = (k[31][0], k[31][1]);
|
||||
let h = x * 1e-4;
|
||||
let after = (c.apply_rolloff(x + h) - top) / h;
|
||||
assert!((after - s).abs() < 0.01, "{s}: right slope {after}");
|
||||
let mut prev = top;
|
||||
for i in 1..3000 {
|
||||
let o = c.apply_rolloff(x * 1.005f32.powi(i));
|
||||
assert!(o.is_finite() && o >= prev && o <= 1.0, "{s} {i}: {o} < {prev}");
|
||||
prev = o;
|
||||
}
|
||||
assert!(1.0 - c.apply_rolloff(x * 64.0) < (1.0 - top) / 4.0, "{s}");
|
||||
for y in [f32::MAX, f32::INFINITY] {
|
||||
assert!((0.0..=1.0).contains(&c.apply_rolloff(y)), "{s} {y}");
|
||||
}
|
||||
}
|
||||
// hostile input
|
||||
for y in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0, f32::MAX] {
|
||||
let o = curve.apply_rolloff(y);
|
||||
assert!(o.is_finite() && (0.0..=1.0).contains(&o), "{y}: {o}");
|
||||
}
|
||||
// the tone map built from it is monotone and bounded, SDR and HDR
|
||||
for peak in [1.0f32, 4.0] {
|
||||
let t = ToneMap::camera_rolloff_hdr(&curve, 0.0, 0.0, 0.0, peak);
|
||||
let mut prev = -1.0;
|
||||
for i in 0..2400 {
|
||||
let o = t.apply(1e-5 * 1.01f32.powi(i));
|
||||
assert!((0.0..=peak).contains(&o) && o >= prev - 1e-6, "{peak} {i}: {o}");
|
||||
prev = o;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_rolloff_joins_the_curve_smoothly_and_never_reaches_white() {
|
||||
for (c, w, b) in [(0.0, 0.0, 0.0), (100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (50.0, -30.0, -40.0), (0.0, -100.0, 0.0)] {
|
||||
for peak in [1.0f32, 4.0] {
|
||||
let (clip, roll) = (ToneMap::hdr(c, w, b, peak), ToneMap::rolloff_hdr(c, w, b, peak));
|
||||
let (clip, roll) = (clip.lut(), roll.lut());
|
||||
// the generic curve up to the knee, never brighter past it
|
||||
let join = roll.iter().zip(clip).position(|(r, c)| r != c).expect("the shoulder starts inside the table");
|
||||
assert!(join > 2 && roll[join] < clip[join], "{c} {w} {b} {peak}: {join}");
|
||||
for (i, (r, c)) in roll.iter().zip(clip).enumerate() {
|
||||
assert!(*r <= c + 1e-6, "{c} {w} {b} {peak} {i}: {r} > {c}");
|
||||
}
|
||||
// same value and slope at the join: the step across it is the step before it
|
||||
let (before, across) = (roll[join - 1] - roll[join - 2], roll[join] - roll[join - 1]);
|
||||
assert!((across / before - 1.0).abs() < 0.02, "{c} {w} {b} {peak}: {before} then {across}");
|
||||
// monotone, and below white where the V1/V2 curve clips (SDR) or lifts to `peak` (HDR)
|
||||
assert!(roll.windows(2).all(|p| p[1] >= p[0] - 1e-6), "{c} {w} {b} {peak}");
|
||||
if peak == 1.0 {
|
||||
assert_eq!(clip[LUT_N - 1], 1.0);
|
||||
// close to it at the top of the table at default settings (10 EV over grey);
|
||||
// Whites −100 moves the white point, and so the shoulder, up 1.6 stops
|
||||
let top = roll[LUT_N - 1];
|
||||
assert!(top < 1.0 && (top > 0.999 || w < 0.0), "{c} {w} {b}: {top}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// no kink where V1/V2 clip: there the clipped curve stops rising at once
|
||||
let (clip, roll) = (ToneMap::new(0.0, 0.0, 0.0), ToneMap::rolloff_hdr(0.0, 0.0, 0.0, 1.0));
|
||||
let kink = |lut: &[f32]| lut.windows(3).filter(|p| p[1] - p[0] > 1e-5).map(|p| 1.0 - (p[2] - p[1]) / (p[1] - p[0])).fold(0.0f32, f32::max);
|
||||
assert!(kink(clip.lut()) > 0.9);
|
||||
assert!(kink(roll.lut()) < 0.05, "{}", kink(roll.lut()));
|
||||
// hostile input (NaN passes through `ToneMap::apply` as on every curve)
|
||||
for y in [f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0, f32::MAX, f32::MIN_POSITIVE] {
|
||||
let o = roll.apply(y);
|
||||
assert!(o.is_finite() && (0.0..=1.0).contains(&o), "{y}: {o}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotone_and_bounded() {
|
||||
for (c, w, b) in [(0.0, 0.0, 0.0), (100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (50.0, -30.0, -40.0)] {
|
||||
|
||||
+2
-1
@@ -17,7 +17,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
|
||||
|---:|---|---|---|---:|---|
|
||||
| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 10 bundled profiles, 52 spectral matrices (plus same-sensor bodies, e.g. the X-T30 through the X-T3) or chart-measured matrices (the EOS D60); others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 1 chart-calibrated model (EOS D60: its Kelvin scale and fallback colour, the look stays the JPEG fit) vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) |
|
||||
| 2 B | **CR3 variants no public file uses** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview. All 110 CC0 CR3s on raw.pixls.us (31 folders) decode from sensor data, so none of these variants has a public sample; 26 CR3 bodies are verified (M50 / R100 / R8 exact, R5 Mark II pinned, 22 more pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)). HEIF stills are `.HIF` files, not CR3; no CR3 sRAW / mRAW sample exists | measured: Canon's share whose camera fully works 73% → 94% (raw-parity.md); 18 of 22 popular Canon bodies write CR3, all 18 now verified | +0.3 points of photographers; the variants surface only as user files that open as a preview | 10–25 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) |
|
||||
| 3 B | **Render fidelity partial** (LR-BEHAV-RENDER-FIDELITY): calibrated camera-look Contrast and Blacks; other operators need measurement | process V3 reduces held-out response error from 0.136 to 0.038 stops for Contrast and 0.424 to 0.077 for Blacks on one Sony camera; Whites remains V2 pending a follow-up | imported Lightroom edits still differ, especially on uncalibrated operators and cameras | 30–60 | [calibrated Light operators](light-v3.md), [process versions](process-versions.md) |
|
||||
| 3 B | **Render fidelity partial** (LR-BEHAV-RENDER-FIDELITY): calibrated camera-look Contrast and Blacks; other operators need measurement; from process V5 raw tone curves roll off into white in stops, so Exposure keeps recorded highlights (#1632) | process V3 reduces held-out response error from 0.136 to 0.038 stops for Contrast and 0.424 to 0.077 for Blacks on one Sony camera; Whites remains V2 pending a follow-up | imported Lightroom edits still differ, especially on uncalibrated operators and cameras | 30–60 | [calibrated Light operators](light-v3.md), [process versions](process-versions.md) |
|
||||
| 4 | ~~Original-pixel detail at 100%~~ (LR-VIEW-ZOOM): closed 2026-10-12, see Closed recently | checklist row ✅ | — | 0 | [ui-parity](ui-parity.md) |
|
||||
| 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) |
|
||||
| 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) |
|
||||
@@ -90,6 +90,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | minor | Gap 3: process V5 rolls a raw's tone curve off into white in stops, so raising Exposure keeps the highlights the sensor recorded (#1632); the gap stays open (no Lightroom comparison) and hours are unchanged |
|
||||
| 2026-10-27 | minor | Gap 26: the loupe shows built previews (they were rendered again), photos ahead and the whole view are prepared in the background with settings per mode; gap not closed |
|
||||
| 2026-10-27 | minor | Gap 26: culling speed measured and improved (progressive Local browse, camera previews made ahead, a CR3's smallest covering JPEG, camera JPEG first at 100 %); what is left listed; gap not closed |
|
||||
| 2026-11-02 | minor | PERF-STEP-STALL: the catalog log is written and synced off the UI thread, so stepping and flagging never wait for the drive (a 300 ms sync: slowest flag 0.2 ms, was 305 ms); a failed write is held and retried, counted as unsaved, never dropped; gap 26 not closed |
|
||||
|
||||
@@ -466,7 +466,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline |
|
||||
| LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) |
|
||||
| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) |
|
||||
| LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | Process V4 calibrates global camera-look Contrast and Blacks against black-box Lightroom exports of Sony ILCE-7CR photos. Held-out response errors are 0.038 and 0.077 stops; V1/V2 keep their rendering. Whites retains V2 and awaits a follow-up. Exposure, Highlights/Shadows, other operators and broader camera coverage remain open (`docs/light-v3.md`). |
|
||||
| LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | Process V4 calibrates global camera-look Contrast and Blacks against black-box Lightroom exports of Sony ILCE-7CR photos. Held-out response errors are 0.038 and 0.077 stops; V1/V2 keep their rendering. Whites retains V2 and awaits a follow-up. Exposure, Highlights/Shadows, other operators and broader camera coverage remain open (`docs/light-v3.md`). From process V5 a raw's tone curve rolls off into white in stops (past a camera curve's last fitted knot, or from half a stop below the generic curve's white point instead of clipping), so Exposure +1 and +2 keep the highlights the sensor recorded: on 12 CC0 corpus raws and 6 CC0 DNGs on the generic curve, unclipped pixels whose luminance renders at 254 or more fall from up to 22.5 % to 0 at +1 EV and from up to 35 % to at most 0.03 % at +2 EV, apart from one sky that is mostly at the clip (#1632). Not compared with Lightroom's output |
|
||||
| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`), `crates/ui-egui/src/destination.rs`, `crates/ui-egui/src/widgets.rs` (`context_menu`), `crates/ui-egui/src/widgets/text_field.rs`, `crates/ui-egui/src/tests/carried_right_click.rs`, `crates/ui-egui/src/tests/carried_text_field_menu.rs` | a right-click while photos are carried opens no context menu (every menu opened through `widgets::context_menu`: sidebar rows, grid cells, lists; a text field's own Cut / Copy / Paste menu too); files → app (a drop-target card while they hover, then the import review); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar); when another folder has the same name, the dragged label, the Move question and the toast name the folder it is in (`home (/a)`); an album drop's toast does the same for an album of that name in another album folder (two in the same place have nothing to tell them apart and keep the plain name); only the primary button drags photos and drops them: a right-button drag over a grid cell neither picks photos up nor changes the selection, and a right-button release while photos are dragged neither drops them nor ends the drag |
|
||||
| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels.rs` | |
|
||||
| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests/dialog_fit.rs`, `crates/ui-egui/src/tests/dialog_copy.rs`, `crates/ui-egui/src/tests/dialogs.rs` (`a_click_outside_closes_the_dialog_when_settings_allow_it`) | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / confirm row stay on screen (the confirm button names its action: Create, Rename, Create Stacks, Export 3 Photos…, with "OK" kept only for a one-field prompt opened with a command that has no verb, `crates/ui-egui/src/tests/dialog_copy.rs`); lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window; Settings → Dialogs → Close a dialog by clicking outside it (off by default, so a stray click never throws away a dialog's choices): a click pressed and released on the dimmed backdrop closes the dialog like Esc and doesn't reach the panels below, while a drag that ends outside (a slider pulled past the edge) or a click that closes a dropdown keeps it; the import review never closes this way (`clicked_backdrop`); a one-line prompt (Rename Folder, New Folder on Disk, the preset and version names, New Library when its name can't be used, Auto-Tag from Tracklog) the engine refuses stays open with its text and shows the reason in a toast, so the name can be corrected, where it used to close without a word; a New Folder on Disk that works says so (“Created folder “Selects” in “2026/Shoot””, naming the parent by its last two folders as dialogs do) |
|
||||
@@ -731,7 +731,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-DEV-CALIB | Calibration panel | P1 | ✅ | `ctl:calibration.*` | |
|
||||
| LRC-DEV-TAT | Targeted adjustment tools | P1 | ✅ | `cmd:develop.targeted` (`target`: curve / hue / sat / lum) | |
|
||||
| LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ✅ | `cmd:library.preferences` (`camera`, `import.perCamera`), `crates/ui-egui/src/dialogs/settings.rs` | Settings → Import: raw default and per-camera presets |
|
||||
| LRC-DEV-PROCESS | Process versions, Update to Current Process | P1 | ✅ | `cmd:develop.updateProcess`, `cmd:develop.reset`, `cmd:develop.get`, `crates/develop/src/settings.rs`, `crates/pipeline/src/finish.rs`, `docs/process-versions.md` | Each photo keeps its saved rendering process. V1 is legacy, V2 preserves Highlights/Shadows detail, V3 is pixel-scale sharpening, V4 calibrates camera-look Contrast and Blacks. New photos and Reset use the latest; Update is one undoable step and never downgrades a future number. Copy/paste/sync preserve the receiving process. Persistent render keys include the resolved process from V3 on; V1/V2 keys remain unchanged. |
|
||||
| LRC-DEV-PROCESS | Process versions, Update to Current Process | P1 | ✅ | `cmd:develop.updateProcess`, `cmd:develop.reset`, `cmd:develop.get`, `crates/develop/src/settings.rs`, `crates/pipeline/src/finish.rs`, `docs/process-versions.md` | Each photo keeps its saved rendering process. V1 is legacy, V2 preserves Highlights/Shadows detail, V3 is pixel-scale sharpening, V4 calibrates camera-look Contrast and Blacks, V5 rolls raw tone curves off into white in stops (#1632). New photos and Reset use the latest; Update is one undoable step and never downgrades a future number. Copy/paste/sync preserve the receiving process. Persistent render keys include the resolved process from V3 on; V1/V2 keys remain unchanged. |
|
||||
| LRC-DEV-VIEWOPTIONS | Develop view options | P2 | 🟡 | `cmd:view.viewOptions`, `crates/ui-egui/src/dialogs/settings.rs` (`view_options`) | View ▸ View Options… (⌘J), the Loupe View part: Show Info Overlay with Info 1 / Info 2, and the three lines of each (none, file name, capture date, dimensions, exposure, camera, lens, camera + lens) with Use Defaults; Show Briefly When Photo Changes under each Info (one at most): with the overlay off, that Info shows for 3 s each time the loupe changes photo; General's Show message when loading or rendering photos (on by default; off, the loupe does not say "Rendering…"); applied as they change, saved with the app settings; `view.viewOptions {info1, info2, briefly, loupeMessages}` sets them for agents. Not yet: the Grid View tab, more fields (ratings, metadata, copy name), the video-frame option |
|
||||
| LRC-DEV-LOUPEOVERLAY | Loupe overlay: layout image | P2 | 🟡 | `cmd:view.chooseLayoutImage`, `cmd:view.layoutImage`, `cmd:view.layoutImageOpacity`, `cmd:view.layoutImageMatte`, `crates/ui-egui/src/panels/layout_overlay.rs` | View ▸ Loupe Overlay: a PNG with transparency (a cover layout, a banner's safe areas) fitted and centred over the loupe photo, following zoom and pan; opacity and matte (the photo darkened outside the graphic's box) in steps; ⌥⌘O shows / hides it; read off the UI thread; saved with the UI state. Not yet: the Grid and Guides overlays, moving and resizing the layout with ⌘ held, free opacity / matte sliders |
|
||||
| LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | |
|
||||
@@ -801,6 +801,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | minor | LR-BEHAV-RENDER-FIDELITY, LRC-DEV-PROCESS: process V5 rolls a raw's tone curve off into white in stops, past a camera curve's last knot and from half a stop below the generic curve's white point, so raising Exposure no longer turns recorded highlights white (#1632); statuses and counts unchanged |
|
||||
| 2026-11-05 | minor | LR-LIB-PEOPLE: face boxes another app drew on the photo as stored land on the upright photo (#2674) |
|
||||
| 2026-11-02 | minor | LRC-DEV-AUTOSYNC (N-A3, note 18): Auto Sync carries white balance as a mode or on each photo's own scale: As Shot and Auto per photo (were the active photo's numbers), a Temp move keeps each photo's tint, the same number on photos of the same scale, a photo of another scale (a JPEG beside a raw) moves by as much; Sync Settings unchanged; status unchanged |
|
||||
| 2026-10-29 | minor | LR-PREF-LOCALSTORAGE, LRC-LIB-PREVIEWS: the preview cache stays uncapped and warns once per session when its drive has under 10 % or 20 GB free, with a button to Settings → Performance; statuses unchanged |
|
||||
|
||||
@@ -12,7 +12,8 @@ stronger Light sliders, issue #196) without changing a single photo someone has
|
||||
| **V1** | LightCraft's rendering from before process versions existed. Frozen. |
|
||||
| **V2** | Highlights and Shadows keep fine detail (issue #632): their base layer (`local::tone_base`) is the edge-aware filter of a separable median of log luminance (radius 0.15 % of the long edge, at most 24 px, `local::BASE_DETAIL`) instead of log luminance itself, so twigs, lines and texture take their surroundings' shift and keep their contrast; region edges stay sharp. CPU (`local::prepare`) and GPU prepare branch on `local::tone_base_detail` (0 = no median under V1); the base plane is cached per process (`local::BaseKey`). Renders without Highlights or Shadows (global or local) are the same as V1's. Tone map unchanged. |
|
||||
| **V3** | Pixel-scale sharpening (issue #757): Radius uses the original photo’s pixels, Detail controls halo damping, and downsized sources retain a response-matched sharpening band. V1 and V2 keep texture-band sharpening exactly. The sharpen plane is keyed by radius and process and invalidated after noise-reduction changes. The Highlights/Shadows base and tone map are unchanged from V2. |
|
||||
| **V4** (the latest) | V3 plus calibrated global Contrast (per channel) and Blacks (per-channel/luminance mix) on raws with a camera tone curve. Whites behaves exactly as V2, both signs; its calibration is a planned follow-up. Other sources and local adjustments retain V2 operators. See [calibrated Light operators](light-v3.md). |
|
||||
| **V4** | V3 plus calibrated global Contrast (per channel) and Blacks (per-channel/luminance mix) on raws with a camera tone curve. Whites behaves exactly as V2, both signs; its calibration is a planned follow-up. Other sources and local adjustments retain V2 operators. See [calibrated Light operators](light-v3.md). |
|
||||
| **V5** (the latest) | V4 plus: a raw's tone curve rolls off into white in stops instead of reaching it within about a stop of the sensor's white, so raising Exposure keeps the highlights the sensor recorded (issue #1632). **Camera curve** (the look fitted to the embedded JPEG, or a DNG's `ProfileToneCurve`; `CameraTone`): past its last knot `CameraTone::apply_rolloff` closes the gap to white by `gap / (1 + c·(2^t − 1))` at `t` stops past the knot, with `c` matching the curve's slope there and the gap halving every stop in the end (`tone::ROLLOFF_STOPS`); `ToneMap::camera_rolloff_hdr` builds the table from it. Under V1 to V4 the curve past its last knot (fitted only to camera JPEG tones below 0.85, so 1 to 3 stops below the sensor's white on most photos) closes on white exponentially in linear light: the sensor's white renders at 8-bit 246 to 254 at Exposure 0 and 254 to 255 from Exposure +1. On the 11 CC0 corpus files with a fitted look (8 Sony, 2 Nikon, 1 Fujifilm) it now renders at 239 to 250 at Exposure 0 and 247 to 253 at +1. A DNG curve that reaches white at the sensor's white renders as before. **Generic curve** (raws without a camera curve, `ToneMap::rolloff_hdr`): from half a stop below its white point (`tone::GENERIC_KNEE_STOPS`) the same shoulder takes over, with the extended Reinhard curve's value and slope at the join, where V1 to V4 clip at the white point (scene 1.18 at default settings, ¼ stop above the sensor's white). The sensor's white renders at about 235, 248 and 252 at Exposure 0, +1 and +2 (V1 to V4: 239, 255, 255; the CC0 Canon R8 file and 6 CC0 DNGs without a camera curve). Content past the old clip point, such as a clipped sky, now renders up to 15 codes below white at Exposure 0 and reaches 255 about 5 stops past the sensor's white. Below the last knot or the join, JPEGs and other display-referred sources, and the camera fit itself are unchanged; CPU and GPU share the tone LUT. |
|
||||
|
||||
## The rule
|
||||
|
||||
@@ -102,4 +103,5 @@ extra field and retain their existing cached renders. No cache-version bump is n
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | minor | V5: a raw's tone curve rolls off into white in stops, so raising Exposure keeps recorded highlights (#1632) |
|
||||
| 2026-10-12 | minor | Calibrated Contrast and Blacks, V2 Whites, resolved-process cache keys and number coordination |
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
hot pixels on a sensor. Stars and other points of light that are kept don't count, so a dense star field still
|
||||
loses its hot pixels. Settings ▸ Performance ▸ Hot pixel limit sets the share (0.05 % to 1 %).
|
||||
|
||||
### Raising Exposure keeps raw highlights (process 5)
|
||||
- Raising Exposure on a raw no longer turns the brightest tones the sensor recorded white (issue #1632). The tone
|
||||
curve now eases into white a stop at a time instead of reaching it within about a stop of the sensor's white: at
|
||||
Exposure +1 and +2 a sky, a lit wall or a face keeps its detail where it used to go flat white. At Exposure 0 the
|
||||
brightest tones render a few codes darker (on average 1 code or less on the photos measured), and fully clipped
|
||||
areas such as a blown sky stay slightly below pure white until Exposure lifts them further. A DNG whose own tone
|
||||
curve already reaches white at the sensor's white renders as before. This is rendering
|
||||
process 5: new photos (and Reset) get it, photos you already edited keep their look until you choose Photo ▸ Update
|
||||
to Current Process. See [process versions](process-versions.md).
|
||||
|
||||
## 2026-11-02
|
||||
|
||||
### The Windows title bar is readable from the moment the window opens
|
||||
|
||||
Reference in New Issue
Block a user