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
+52 -66
View File
@@ -12,10 +12,13 @@
//! Fusing them would force a full decode where a header read suffices, which
//! is exactly why Lightroom stalls ~2 s per image during culling.
pub mod base_curve;
mod error;
mod locate;
mod preview;
pub mod profile;
pub use base_curve::BaseCurve;
pub use error::DecodeError;
pub use locate::{
defects, is_complete_jpeg, locate_preview, BadLine, BadPixel, Defects, PreviewLocation,
@@ -25,6 +28,7 @@ pub use preview::{
decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize,
PREVIEW_PROBE_BYTES,
};
pub use profile::CameraProfile;
use dr_types::{Format, Orientation};
@@ -89,7 +93,24 @@ pub struct RawImage {
/// `None` where the body is unknown to the decoder, in which case the
/// pipeline falls back to identity and the result is uncalibrated rather
/// than wrong-by-a-guess.
///
/// Where the body carries two calibrations this is already *interpolated*
/// for the light the frame was shot under; see [`profile::CameraProfile`].
pub color_matrix: Option<[f32; 9]>,
/// TRACES: FR-DEV-3e
/// The per-body rendering curve, the other half of the camera profile.
///
/// The matrix above decides what the colours *are*; this decides what the
/// picture looks like. Carried on the decoded image rather than looked up
/// downstream because this is the only point in the system that knows
/// which body took the frame, and because it is not an edit: it belongs to
/// the file in the same way the masked-photosite crop does, and must never
/// reach a sidecar (FR-NC-9).
///
/// [`BaseCurve::IDENTITY`] for an unknown body with no default in the
/// database, which renders exactly as this decoder did before profiles
/// existed.
pub base_curve: BaseCurve,
/// The usable region of `data`, excluding masked and border photosites.
pub crop: CropRect,
}
@@ -421,15 +442,38 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
let source = RawSource::new_from_slice(bytes);
let decoder =
rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?;
// Read before decoding, while the decoder is still the cheapest thing in
// the room. These are the DNG tags rawler parses into its IFD and then
// never surfaces — `ForwardMatrix1/2` above all — and they are empty for
// every non-DNG file, which is not a failure (FR-DEV-3e).
let dng = profile::read_dng_matrices(decoder.as_ref());
let image = decoder
.raw_image(&source, &Default::default(), false)
.map_err(|e| DecodeError::Decode(e.to_string()))?;
// Both derived before the match below moves `image.data`, and from the
// same matrix: the balance and the conversion must agree about which white
// is neutral or the frame carries a cast that looks like a decode fault.
let color_matrix = cam_to_srgb(&image);
let wb_coeffs = sane_wb(image.wb_coeffs, xyz_to_cam_of(&image).as_ref());
// The camera profile, and both of the things derived from it, are built
// before the match below moves `image.data`.
//
// They come from *one* profile deliberately: the balance and the
// conversion must agree about which white is neutral, or the frame carries
// a cast that looks like a decode fault. That agreement used to be
// maintained by hand — two functions reading the same illuminant key — and
// is now structural, because there is only one interpolated matrix and
// both callers ask the same object for it.
let profile = profile::CameraProfile::extract(&image, &dng);
let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb());
let wb_coeffs = sane_wb(image.wb_coeffs, profile.as_ref().map(|p| p.xyz_to_cam()).as_ref());
// The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings
// are preferred where it has them — they are what the shipped database is
// written against — and the matching folds the variants either way, so a
// DNG naming the same body differently still finds its curve.
let base_curve = base_curve::for_body(
image.camera.clean_make.as_str(),
image.camera.clean_model.as_str(),
);
let data = match image.data {
rawler::RawImageData::Integer(v) => v,
@@ -498,68 +542,10 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
.unwrap_or(u16::MAX),
wb_coeffs,
color_matrix,
base_curve,
})
}
/// TRACES: FR-DEV-3e
/// Compose the camera→sRGB-linear matrix from rawler's XYZ→camera.
///
/// **Two rawler traps this avoids**, both measured on a Canon 6D CR2
/// (2026-08-09):
///
/// 1. `RawImage::xyz_to_cam` is **all zeros** — it carries an upstream
/// deprecation note and 0.7.2 no longer fills it. The live data is
/// `color_matrix`, keyed by illuminant. Reading the old field silently
/// yields no colour transform at all.
/// 2. `cam_to_xyz_normalized()` divides each of four rows by its own sum, and
/// the fourth row (emerald/white, unused on any Bayer body) sums to zero.
/// Every element came back `NaN`. Inverting the 3×3 ourselves avoids the
/// fourth channel entirely.
fn cam_to_srgb(image: &rawler::RawImage) -> Option<[f32; 9]> {
use rawler::imgop::xyz::Illuminant;
// Prefer D65 — it matches sRGB's white point, so no chromatic adaptation
// is needed. Illuminant A (tungsten) is a distant fallback for bodies
// that ship only one matrix; adapting it properly is a v0.2 colour-
// management concern (ARCH §5.2), not something to fake here.
let flat = image
.color_matrix
.get(&Illuminant::D65)
.or_else(|| image.color_matrix.get(&Illuminant::A))?;
if flat.len() < 9 {
return None;
}
let xyz_to_cam: [[f32; 3]; 3] = [
[flat[0], flat[1], flat[2]],
[flat[3], flat[4], flat[5]],
[flat[6], flat[7], flat[8]],
];
cam_to_srgb_from(&xyz_to_cam)
}
/// The camera's XYZ→camera matrix, as rawler holds it.
///
/// Split out so the white-balance fallback and the colour matrix read the same
/// data through the same illuminant preference; two readers disagreeing about
/// which matrix a body uses would balance to one white and convert from
/// another.
fn xyz_to_cam_of(image: &rawler::RawImage) -> Option<[[f32; 3]; 3]> {
use rawler::imgop::xyz::Illuminant;
let flat = image
.color_matrix
.get(&Illuminant::D65)
.or_else(|| image.color_matrix.get(&Illuminant::A))?;
if flat.len() < 9 {
return None;
}
Some([
[flat[0], flat[1], flat[2]],
[flat[3], flat[4], flat[5]],
[flat[6], flat[7], flat[8]],
])
}
/// The matrix maths, split out so it can be tested without a RAW file.
//
// The constants below are quoted at their published precision rather than
@@ -567,7 +553,7 @@ fn xyz_to_cam_of(image: &rawler::RawImage) -> Option<[[f32; 3]; 3]> {
// a linter makes it harder to check against the specification, and the
// rounding happens identically either way.
#[allow(clippy::excessive_precision)]
fn cam_to_srgb_from(xyz_to_cam: &[[f32; 3]; 3]) -> Option<[f32; 9]> {
pub(crate) fn cam_to_srgb_from(xyz_to_cam: &[[f32; 3]; 3]) -> Option<[f32; 9]> {
// XYZ (D65) → linear sRGB, the standard primaries.
const XYZ_TO_SRGB: [[f32; 3]; 3] = [
[3.2404542, -1.5371385, -0.4985314],
@@ -722,7 +708,7 @@ pub fn daylight_wb(xyz_to_cam: &[[f32; 3]; 3]) -> Option<[f32; 3]> {
}
/// Invert a 3×3 matrix, or `None` if it is singular.
fn invert3(m: &[[f32; 3]; 3]) -> Option<[[f32; 3]; 3]> {
pub(crate) fn invert3(m: &[[f32; 3]; 3]) -> Option<[[f32; 3]; 3]> {
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);