Render each body through the profile its own files describe

Colour came from whichever matrix rawler happened to key `D65`, the second
one was discarded, and the rendering was left linear. That is the dcraw
default, and FR-DEV-3e names it as the reason people abandon a converter in
the first hour: correct in the abstract, flat and poor on skin in practice.

The decoder now builds a camera profile.

- `ColorMatrix1/2` and `CalibrationIlluminant1/2`. rawler surfaces these as
  an illuminant-keyed map — for DNGs from the tags, and for native formats
  from its own camera database — so a Canon CR2 arrives with a tungsten
  matrix and a daylight matrix exactly as an Adobe DNG of the same frame
  would. Dual-illuminant support is therefore not a DNG feature here.

- `ForwardMatrix1/2`, read straight from the root IFD, because rawler parses
  them and never surfaces them. Where a file carries both, they replace the
  inverted colour matrix: the same relationship measured in the direction
  rendering actually wants, rather than an inversion that amplifies the
  measurement error exactly where skin lives.

- `AsShotNeutral`, used to estimate what the scene was lit by and to
  interpolate between the two calibrations in mireds. The estimate is
  circular — the temperature needs a matrix and the matrix needs the
  temperature — so it is a fixed point, three rounds, as Adobe's SDK does it.

Bodies calibrated at neither D65 nor A stopped rendering uncalibrated as a
side effect: a Phase One IQ3 carries D55 and D75 and used to get no matrix
at all.

And a base curve, applied per channel in camera RGB between the last
adjustment and the conversion out of camera space — a toe, a steep midtone
and a shoulder, which is the difference between a photograph and a scan of
one. It is not an edit: no slider, nothing in the sidecar, because it
belongs to the body rather than to anything anyone decided, and a sidecar is
shared between bodies. It is not a develop node either, and `ops/README.md`
now records why. It evaluates on the tone curve's own spline rather than a
second copy, so a profile author placing a control point and a photographer
dragging one mean the same thing by it.

The curves are data. `core/dr-decode/profiles/base_curves.yaml` ships inside
the binary as a floor and is superseded by any copy on disk carrying a
higher `version:`, so a body can be added and distributed without a release
— and, under the GPL, contributed. The comparison runs both ways: a stale
pack cannot hold an upgraded binary back at last year's rendering.

Canon EOS 6D and R6, Nikon Z 6 and D750, Sony A7 III and Fujifilm X-T3 ship
with their own curves. Every other body gets a conservative default, which
is much closer to right than the identity is for any of them. A JPEG gets
none — it has already been rendered once, by the camera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 14:37:33 +02:00
co-authored by Claude Opus 5
parent 586698db00
commit 743fefe7f1
12 changed files with 2396 additions and 108 deletions
+27 -1
View File
@@ -32,6 +32,16 @@ use crate::{DemosaicedImage, GpuContext, GpuError};
/// reads them.
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
/// TRACES: FR-DEV-3e
/// The two crates must agree on how many points a base curve has.
///
/// `dr-decode` reads them from the profile database and `dr-pipeline` declares
/// the uniform slots; this file is the only place the two meet, and it packs
/// them by index. A disagreement would not fail to compile — it would upload a
/// curve with a point missing or a stale float in it, which renders as a
/// plausible-looking wrong tone response. Cheaper to catch here, at build time.
const _: () = assert!(dr_decode::base_curve::POINTS == dr_pipeline::BASE_CURVE_POINTS);
/// Runs composed operation chains against demosaiced images.
pub struct AdjustPass {
ctx: GpuContext,
@@ -332,6 +342,18 @@ impl AdjustPass {
// runs. See `DemosaicedImage::is_non_linear`.
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
// TRACES: FR-DEV-3e
// The camera profile's base curve, packed the way the generated block
// declares it: four x, four y, then the fifth point and the flag. The
// flag is what lets one compiled shader serve a profiled body and an
// unprofiled one, so the pipeline cache is not split in two by which
// camera took the frame.
let curve = source.base_curve();
let on = if curve.is_identity() { 0.0 } else { 1.0 };
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
uniforms[b..b + 4].copy_from_slice(&curve.xs[0..4]);
uniforms[b + 4..b + 8].copy_from_slice(&curve.ys[0..4]);
uniforms[b + 8..b + 12].copy_from_slice(&[curve.xs[4], curve.ys[4], on, 0.0]);
let params_buf = self
.ctx
@@ -512,7 +534,7 @@ fn numbered(src: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_pipeline::ops::{colour_mixer, exposure, saturation};
use dr_pipeline::EditGraph;
@@ -546,6 +568,7 @@ mod tests {
// Identity, so the test reasons about the operations alone
// rather than about a camera's colour response.
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
crop: CropRect {
x: 0,
y: 0,
@@ -719,6 +742,7 @@ mod tests {
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
crop: CropRect {
x: 0,
y: 0,
@@ -1141,6 +1165,7 @@ mod tests {
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
crop: CropRect {
x: 0,
y: 0,
@@ -1240,6 +1265,7 @@ mod tests {
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
crop: CropRect {
x: 0,
y: 0,
+34 -1
View File
@@ -10,7 +10,7 @@
//! pass over this texture; it does not re-demosaic, which is what keeps the
//! interaction budget (NFR-P9) reachable on a 24 MP file.
use dr_decode::{CfaPattern, RawImage};
use dr_decode::{BaseCurve, CfaPattern, RawImage};
use wgpu::util::DeviceExt;
use crate::{GpuContext, GpuError};
@@ -83,6 +83,16 @@ pub struct DemosaicedImage {
color_matrix: [f32; 9],
/// As-shot white balance, the neutral starting point for the WB control.
as_shot_wb: [f32; 3],
/// TRACES: FR-DEV-3e
/// The camera profile's rendering curve, carried through for the adjust
/// pass exactly as `color_matrix` is.
///
/// It rides on the image rather than on the edit graph because it is not
/// an edit: it belongs to the body that took the frame, the way the
/// masked-photosite crop and the EXIF orientation do, and a sidecar shared
/// between two bodies must never carry one body's rendering onto the
/// other's file (FR-NC-9).
base_curve: BaseCurve,
/// Whether the texture holds gamma-encoded rather than linear values.
non_linear: bool,
}
@@ -108,6 +118,15 @@ impl DemosaicedImage {
self.color_matrix
}
/// TRACES: FR-DEV-3e
/// The camera profile's base curve, as five `(x, y)` points.
///
/// [`BaseCurve::IDENTITY`] where the body is unprofiled or the source was
/// never raw, in which case the adjust pass skips the stage entirely.
pub fn base_curve(&self) -> BaseCurve {
self.base_curve
}
/// As-shot white balance multipliers, green-normalised.
///
/// The white balance control is expressed *relative* to these, so its
@@ -220,6 +239,12 @@ impl DemosaicedImage {
height,
color_matrix: IDENTITY_3X3,
as_shot_wb: [1.0, 1.0, 1.0],
// **The identity, and this is the whole reason the field is here
// rather than resolved further down.** A JPEG has already had its
// camera's base curve baked in by the camera; applying one again
// would render the rendering, crushing the shadows and flattening
// the highlights of an image that was already finished.
base_curve: BaseCurve::IDENTITY,
non_linear: true,
})
}
@@ -495,6 +520,10 @@ impl Demosaicer {
// no colour transform rather than not at all.
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
// Whatever the profile database had for this body (FR-DEV-3e),
// resolved at decode because that is the only place the make and
// model are known.
base_curve: raw.base_curve,
// Sensor data is linear by construction — the demosaic shader
// normalises against black and white levels and applies no
// transfer function.
@@ -808,6 +837,7 @@ mod tests {
white_level: white,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None,
base_curve: BaseCurve::IDENTITY,
crop: CropRect {
x: 0,
y: 0,
@@ -919,6 +949,7 @@ mod tests {
white_level: white,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None,
base_curve: BaseCurve::IDENTITY,
crop: CropRect {
x: 0,
y: 0,
@@ -1164,6 +1195,7 @@ mod tests {
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None,
base_curve: BaseCurve::IDENTITY,
crop: CropRect {
x: 0,
y: 0,
@@ -1244,6 +1276,7 @@ mod tests {
1.0,
],
color_matrix: None,
base_curve: BaseCurve::IDENTITY,
crop: CropRect {
x: 0,
y: 0,