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:
+52
-66
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user