Render raws through the DNG reference curve by default, at contrast 1.5

Decides D21 by measurement. The photo gallery holds Lightroom 6 exports of
raws in the library, each carrying its Camera Raw settings; clustered by
those settings, 663 had no look applied. On 60 of them with their raws,
a third held out, the held-out MSE against Lightroom's JPEG was about 1200
for 0.20.0's sigmoid (0.7 EV darker and flatter), 224 for the DNG reference curve
after baseline exposure, and about 140 once its input is bent by 1.5/1.4
about grey.

So the curve choice defaults to the DNG reference, keeping its index (sidecars
record it), and the default contrast is 1.5. Contrast under the DNG reference curve is
now a power relative to REFERENCE_CONTRAST (1.4), where the table is
untouched; the sigmoid at that contrast still matches the retired base
curve. JPEGs are unaffected: the view transform skips a rendered source.
This commit is contained in:
2026-10-03 15:54:21 -04:00
parent b69fb3e191
commit a4ff7ec2b9
9 changed files with 108 additions and 32 deletions
+8 -1
View File
@@ -3420,8 +3420,15 @@ mod tests {
read_centre(&ctx, t)
};
// The default rendering is Camera Raw's curve (D21); for a grey its
// ProPhoto round trip is the identity, so the reference applies as is.
let scene = 3537.0 / 16383.0;
let viewed = dr_pipeline::view::Sigmoid::default_curve().channel(scene);
let viewed = dr_pipeline::camera_raw::apply_reference(
&dr_types::tone::ACR3_DEFAULT,
[scene; 3],
dr_pipeline::view::DEFAULT_CONTRAST,
dr_pipeline::view::DEFAULT_WHITE,
)[0];
let expected = (dr_types::Transfer::Srgb.encode(viewed) * 255.0).round() as i32;
let delta = (i32::from(from_sensor[0]) - expected).abs();
assert!(
+7
View File
@@ -74,6 +74,13 @@ fn camera_raw_tone_agrees_with_the_acr3_curve() {
dr_pipeline::ops::view_transform::CURVE,
dr_pipeline::ops::view_transform::CAMERA_RAW,
);
// The table itself, so its own contrast: the default bends the input a
// little past it (D21).
graph.set_param(
dr_pipeline::ops::view_transform::ID,
dr_pipeline::ops::view_transform::CONTRAST,
dr_pipeline::view::REFERENCE_CONTRAST,
);
for level in [500u16, 4_000, 8_520, 20_000, 40_000] {
let scene = f32::from(level) / f32::from(u16::MAX);
let display = dr_types::tone::evaluate(&dr_types::tone::ACR3_DEFAULT, scene);
+20 -8
View File
@@ -19,7 +19,7 @@
//! shader to it.
use crate::ops::camera_profile::{mul, working_prophoto};
use crate::view::{DEFAULT_CONTRAST, DEFAULT_WHITE, SCENE_GREY};
use crate::view::{DEFAULT_CONTRAST, DEFAULT_WHITE, REFERENCE_CONTRAST, SCENE_GREY};
/// The input scale for a white point: 1 at the default, so sensor white is
/// display white as in the SDK's reference; each stop of `white` above it halves the
@@ -28,10 +28,14 @@ pub fn input_scale(white: f32) -> f32 {
(DEFAULT_WHITE - white).exp2()
}
/// The power the input is bent by about middle grey: 1 at the default
/// contrast, so the curve is the reference's untouched.
/// The power the input is bent by about middle grey: 1 at
/// [`REFERENCE_CONTRAST`], where the curve is the reference's untouched.
///
/// The default contrast sits above it, so a photograph out of the camera is
/// bent by `DEFAULT_CONTRAST / REFERENCE_CONTRAST` — the extra contrast
/// Lightroom's exports showed over the bare reference curve (D21 addendum).
pub fn contrast_power(contrast: f32) -> f32 {
contrast / DEFAULT_CONTRAST
contrast / REFERENCE_CONTRAST
}
/// The curve, its scale and its contrast applied to one ProPhoto colour.
@@ -103,15 +107,23 @@ mod tests {
}
#[test]
fn at_its_defaults_the_input_is_untouched() {
fn at_the_reference_the_input_is_untouched() {
assert_eq!(input_scale(DEFAULT_WHITE), 1.0);
assert_eq!(contrast_power(DEFAULT_CONTRAST), 1.0);
assert_eq!(contrast_power(REFERENCE_CONTRAST), 1.0);
}
#[test]
fn the_default_adds_the_contrast_lightroom_showed() {
// Fitted on Lightroom exports with neutral settings (D21 addendum):
// the bare reference curve is a little flat against them.
let p = contrast_power(DEFAULT_CONTRAST);
assert!((1.05..1.12).contains(&p), "{p}");
}
#[test]
fn grey_goes_through_the_curve_and_stays_grey() {
for v in [0.02, 0.13, 0.5] {
let out = apply_reference(&ACR3_DEFAULT, [v; 3], DEFAULT_CONTRAST, DEFAULT_WHITE);
let out = apply_reference(&ACR3_DEFAULT, [v; 3], REFERENCE_CONTRAST, DEFAULT_WHITE);
let want = evaluate(&ACR3_DEFAULT, v);
assert!(
out.iter().all(|o| (o - want).abs() < 1e-4),
@@ -123,7 +135,7 @@ mod tests {
#[test]
fn an_identity_curve_changes_nothing_inside_the_range() {
let c = [0.4, 0.2, 0.1];
let out = apply_reference(&identity(), c, DEFAULT_CONTRAST, DEFAULT_WHITE);
let out = apply_reference(&identity(), c, REFERENCE_CONTRAST, DEFAULT_WHITE);
assert!(
out.iter().zip(c).all(|(o, c)| (o - c).abs() < 1e-4),
"{out:?}"
+9
View File
@@ -464,6 +464,15 @@ impl ParamDescriptor {
}
}
/// The same choice with another variant as its default.
///
/// For a choice whose variants were numbered before its default was
/// settled: a sidecar records the index, so reordering the variants to
/// put the default first would change what saved edits mean.
pub fn with_default(self, default: f32) -> Self {
Self { default, ..self }
}
/// A 0…1 fraction — a proportion of something, rather than an amount.
///
/// Its own constructor because the crop rect needs four of them and the
+11 -7
View File
@@ -34,10 +34,13 @@ pub const ID: OpId = OpId("view_transform");
pub const CONTRAST: ParamId = ParamId("contrast");
pub const WHITE: ParamId = ParamId("white");
/// TRACES: FR-DEV-3j
/// Which curve renders: D19's sigmoid (index 0, the default) or Camera
/// Raw's (D21). Which of the two should be the default is open — the
/// measurement work decides it — so the default is what 0.20.0
/// rendered.
/// Which curve renders: Camera Raw's (D21, the default) or D19's sigmoid.
///
/// Camera Raw's because it is the one that matches what the photographs were
/// first developed with: on 60 Lightroom exports with neutral settings it
/// renders their raws within MSE ~150 of Lightroom's own JPEGs at the default
/// contrast, where 0.20.0's sigmoid was ~1200 (darker by about 0.7 EV and
/// flatter). The sigmoid keeps index 0 because sidecars record the index.
pub const CURVE: ParamId = ParamId("curve");
/// [`CURVE`]'s values, in the order of its variants.
@@ -93,7 +96,8 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
LocalizedKey("param.view_transform.curve.sigmoid"),
LocalizedKey("param.view_transform.curve.camera_raw"),
],
),
)
.with_default(CAMERA_RAW),
],
})
});
@@ -110,7 +114,7 @@ impl Default for ViewTransform {
Self {
contrast: DEFAULT_CONTRAST,
white: DEFAULT_WHITE,
curve: SIGMOID,
curve: CAMERA_RAW,
}
}
}
@@ -150,7 +154,7 @@ impl Operation for ViewTransform {
}
fn is_active(&self) -> bool {
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE || self.curve != SIGMOID
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE || self.curve != CAMERA_RAW
}
fn stage(&self) -> Stage {
+19 -6
View File
@@ -51,8 +51,19 @@ pub const SCENE_GREY: f32 = 0.13;
/// Display-linear middle grey — what a camera JPEG shows a grey card as.
pub const DISPLAY_GREY: f32 = 0.18;
/// The default contrast, the sigmoid's log-log slope parameter `n`.
pub const DEFAULT_CONTRAST: f32 = 1.4;
/// The default contrast: the sigmoid's log-log slope `n`, and for Camera
/// Raw's curve a power of `DEFAULT_CONTRAST / REFERENCE_CONTRAST` about grey.
///
/// Fitted, not chosen: on Lightroom 6 exports whose look settings were neutral,
/// Camera Raw's curve matched the exports best with the input bent by about
/// 1.08 (held-out MSE 224 at 1.4, ~150 at 1.5). The sigmoid, which is no longer
/// the default, fitted best near 1.7 and is better at 1.5 than at 1.4.
pub const DEFAULT_CONTRAST: f32 = 1.5;
/// The contrast at which each curve is its own reference: the sigmoid's match
/// to the retired base curve (see the module note), and Camera Raw's table
/// untouched.
pub const REFERENCE_CONTRAST: f32 = 1.4;
/// The default white point, in stops above [`SCENE_GREY`].
pub const DEFAULT_WHITE: f32 = 4.0;
@@ -223,11 +234,13 @@ mod tests {
}
#[test]
fn the_default_stays_close_to_the_retired_curve() {
fn the_reference_stays_close_to_the_retired_curve() {
// TRACES: FR-DEV-3j | FR-DEV-3e
// D19's promise to every existing photograph: the midtones do not
// move by more than a third of a stop.
let s = Sigmoid::default_curve();
// D19's promise, now kept by the sigmoid at its reference contrast
// rather than by the default (D21 moved the default to Camera Raw's
// curve): the midtones do not move by more than a third of a stop
// from the retired base curve.
let s = Sigmoid::new(REFERENCE_CONTRAST, DEFAULT_WHITE);
let mut x = 0.03_f32;
while x <= 1.0 {
let ev = (s.channel(x) / retired_default(x)).log2();
+6 -3
View File
@@ -295,10 +295,13 @@ that rendering, chosen by a new parameter on `view_transform`:
| `curve` | What it is | Default for |
|---|---|---|
| Sigmoid | D19's log-logistic curve | every raw, for now |
| DNG reference | the profile's curve, else ACR3, via RGBTone in ProPhoto | — (D21: the default is open) |
| Sigmoid | D19's log-logistic curve | — (a choice) |
| DNG reference | the profile's curve, else ACR3, via RGBTone in ProPhoto | every raw (D21) |
*Amended 2026-10-03:* the DNG reference was the default in the first draft. The measurement it rested on
*Decided 2026-10-03:* the DNG reference is the default, at contrast 1.5 — measured against
Lightroom exports of photographs with neutral look settings (D21 has the table).
*Amended earlier on 2026-10-03:* the DNG reference was the default in the first draft. The measurement it rested on
compared against Lightroom renders of *edited* photographs; see D21. The default is decided by
measuring against Lightroom exports of unedited ones.
+24 -3
View File
@@ -2437,7 +2437,7 @@ Rationale, evidence, and the eliminated alternatives are recorded in
| D12 | Scope versus pace | **DECIDED 2026-09-19** — settled by events; full scope stands, no v1 date |
| D18 | Derived images | **DECIDED 2026-09-19** — a merge writes a new source file; no multi-source Version |
| D19 | Scene-referred pipeline | **DECIDED 2026-09-27** — edits on unbounded scene-linear colour; one view transform, last; per-body base curves retired |
| D21 | DNG reference tone for raws | **PARTLY DECIDED 2026-10-03** — the view transform gains the DNG reference curve (profile's, else ACR3 default, via RGBTone in ProPhoto) as a choice; which curve is the default is open, pending measurement against Lightroom exports |
| D21 | DNG reference tone for raws | **DECIDED 2026-10-03** — the view transform's DNG reference curve (profile's, else ACR3 default, via RGBTone in ProPhoto) is the default for every raw, at contrast 1.5 (a ×1.07 power about grey); measured against Lightroom exports of photographs with neutral look settings; the sigmoid stays a choice |
| D20 | DCP camera profiles | **DECIDED 2026-10-02** — HueSatMap and LookTable as a scene operation after exposure; embedded profile first, then a matched `.dcp`; tone curve not applied; none shipped |
### D11 — product positioning
@@ -2773,9 +2773,30 @@ matrix does.
*What it costs.* Every DNG with an embedded profile renders differently; previews refresh only when
rendered again; tablet and desktop release together. The profiles directory does not sync yet.
### D21 — DNG reference tone for raws · **PARTLY DECIDED 2026-10-03**
### D21 — DNG reference tone for raws · **DECIDED 2026-10-03**
*Amended the same day:* the default is **not** decided. The measurement below was against
*Decided by measurement, later the same day.* The library's photo gallery holds Lightroom 6
exports of raws that are in the library, each carrying its Camera Raw settings. Clustered by
those settings, 663 exports had none of the house look (Linear curve, no HSL, no parametric
curve, no split toning); 60 of them with their raws, two thirds fitted and one third held out,
rendered by DarkRoom against Lightroom's JPEG (MSE, sRGB 8-bit):
| Rendering | Held-out MSE |
|---|---|
| 0.20.0's sigmoid at its defaults | ~1200 — about 0.7 EV darker, and flatter |
| Sigmoid, exposure, contrast and white fitted | ~150 (contrast 1.73, +0.73 EV) |
| Camera Raw's curve after baseline exposure, at contrast 1.4 | 224 |
| Camera Raw's curve, contrast 1.5 | ~150 |
| Camera Raw's curve, exposure, contrast and white fitted | 143 |
So Camera Raw's curve is the default for every raw, and the default contrast is 1.5 — under that
curve a power of 1.5/1.4 about grey (`REFERENCE_CONTRAST` is where the curve is untouched). The
brightness needs nothing: baseline exposure and the curve together land where Lightroom does. The
profile's look strength, vibrance and saturation bought nothing measurable on those exports. The
user chose to change every photograph rather than keep edited ones on the old rendering. The
fitting tools live outside the repository (`darkroom-lrfit`).
*Amended earlier the same day:* the default was **not** decided. The measurement below was against
Lightroom previews of photographs carrying the user's Lightroom edits — a house look of HSL
saturation (Blue +58, Aqua +50, …), Highlights −40 and Blacks −20 in every DNG's XMP — so it said
nothing about Camera Raw's base rendering. Under Camera Raw's curve `_MG_9080` renders brighter
File diff suppressed because one or more lines are too long