Read baseline exposure and profile tone curves; carry the ACR3 curve

The decoding half of camera-profiles.md §11-§12. RawImage gains
baseline_exposure: the file's BaselineExposure plus the chosen
profile's BaselineExposureOffset, as the DNG SDK sums them (+0.25 for
the library's 6D DNGs). A profile copied out of a DNG carries that
DNG's baseline as its offset, so the body's CR2s, which have none,
land at the same total.

ProfileTables gains the profile's ProfileToneCurve, resampled at
decode onto 1025 points with a natural cubic spline; an identity curve
counts as none. dr-types now holds Camera Raw's ACR3 default curve,
RawTherapee's adobe_camera_raw_default_curve copied value for value,
for every raw whose profile has no curve. Nothing renders through
either yet.
This commit is contained in:
2026-10-03 14:22:46 -04:00
parent b58873ef57
commit 38d414912c
19 changed files with 400 additions and 35 deletions
+48 -1
View File
@@ -68,6 +68,11 @@ pub struct Dcp {
/// `ProfileToneCurve`, as stored: input/output pairs. Carried so a copy
/// keeps it, never applied — tone is the view transform's (D19, D20).
pub tone_curve: Option<Vec<f32>>,
/// `BaselineExposureOffset`, in stops: the profile's correction to the
/// file's `BaselineExposure` (camera-profiles.md §11). A profile copied
/// out of a DNG carries that DNG's baseline here, so a raw with no
/// baseline of its own lands at the same brightness.
pub baseline_exposure_offset: f32,
}
impl Dcp {
@@ -120,6 +125,7 @@ impl Dcp {
.get_entry(DngTag::ProfileToneCurve)
.and_then(|e| floats(&e.value))
.filter(|v| v.len() >= 4 && v.len() % 2 == 0),
baseline_exposure_offset: stops(ifd, DngTag::BaselineExposureOffset),
})
}
@@ -218,6 +224,10 @@ impl Dcp {
origin,
hue_sat: hue_sat.filter(|t| !t.is_identity()),
look: self.look.clone().filter(|t| !t.is_identity()),
tone_curve: self
.tone_curve
.as_deref()
.and_then(dr_types::tone::resample_tone_curve),
}
}
@@ -305,6 +315,15 @@ impl Dcp {
if let Some(curve) = &self.tone_curve {
dir.add_value(DngTag::ProfileToneCurve, Value::Float(curve.clone()));
}
if self.baseline_exposure_offset != 0.0 {
dir.add_value(
DngTag::BaselineExposureOffset,
Value::SRational(vec![SRational::new(
(self.baseline_exposure_offset * 100.0).round() as i32,
100,
)]),
);
}
writer.build(dir).map_err(|e| e.to_string())?;
let mut bytes = cursor.into_inner();
// The writer stamps TIFF's 42 in its own byte order; a profile is the
@@ -340,6 +359,15 @@ fn string(ifd: &IFD, tag: DngTag) -> Option<String> {
.filter(|s| !s.is_empty())
}
/// A single rational tag in stops, zero where absent or unreadable — the
/// DNG specification's default for both exposure tags.
fn stops(ifd: &IFD, tag: DngTag) -> f32 {
ifd.get_entry(tag)
.and_then(|e| e.value.get_f32(0).ok().flatten())
.filter(|v| v.is_finite())
.unwrap_or(0.0)
}
fn floats(value: &Value) -> Option<Vec<f32>> {
(0..value.count())
.map(|i| value.get_f32(i).ok().flatten())
@@ -507,7 +535,12 @@ pub fn embedded_in(bytes: &[u8]) -> Option<Dcp> {
.ifd(rawler::decoders::WellKnownIFD::Root)
.ok()
.flatten()?;
Dcp::from_ifd(&root)
// The copy carries the file's baseline as its offset, so a raw from the
// same body that has no baseline of its own — a CR2 — gets the total the
// DNG renders at (camera-profiles.md §11).
let mut profile = Dcp::from_ifd(&root)?;
profile.baseline_exposure_offset += stops(&root, DngTag::BaselineExposure);
Some(profile)
}
/// TRACES: FR-DEV-3e
@@ -518,6 +551,9 @@ pub struct Resolved {
pub profile: Option<CameraProfile>,
pub tables: Option<Arc<ProfileTables>>,
pub embedded: Option<Arc<Dcp>>,
/// Stops to add at render: the file's `BaselineExposure` plus the
/// chosen profile's `BaselineExposureOffset` (camera-profiles.md §11).
pub baseline_exposure: f32,
}
/// TRACES: FR-DEV-3e
@@ -532,6 +568,7 @@ pub fn resolve(
model: &str,
) -> Resolved {
let embedded = root.and_then(Dcp::from_ifd).map(Arc::new);
let file_baseline = root.map_or(0.0, |r| stops(r, DngTag::BaselineExposure));
if let Some(dcp) = &embedded {
let tables = matrices
.as_ref()
@@ -541,6 +578,7 @@ pub fn resolve(
return Resolved {
profile: matrices,
tables,
baseline_exposure: file_baseline + dcp.baseline_exposure_offset,
embedded,
};
}
@@ -553,6 +591,7 @@ pub fn resolve(
tables: (!tables.is_empty()).then(|| Arc::new(tables)),
profile: Some(own),
embedded: None,
baseline_exposure: file_baseline + dcp.baseline_exposure_offset,
};
}
}
@@ -560,6 +599,7 @@ pub fn resolve(
profile: matrices,
tables: None,
embedded: None,
baseline_exposure: file_baseline,
}
}
@@ -609,6 +649,7 @@ mod tests {
],
look: Some(table(4, 2, 3, [0.0, 1.2, 0.95])),
tone_curve: Some(vec![0.0, 0.0, 0.5, 0.6, 1.0, 1.0]),
baseline_exposure_offset: 0.25,
}
}
@@ -627,6 +668,7 @@ mod tests {
assert_eq!(back.unique_camera_model, original.unique_camera_model);
assert_eq!(back.illuminants, original.illuminants);
assert_eq!(back.tone_curve, original.tone_curve);
assert_eq!(back.baseline_exposure_offset, 0.25);
assert_eq!(
back.forward_matrix, original.forward_matrix,
"four decimals, as the file has"
@@ -746,6 +788,11 @@ mod tests {
assert_eq!(tables.origin, ProfileOrigin::Embedded);
assert_eq!(tables.name, "Adobe Standard");
assert!(tables.hue_sat.is_some() && tables.look.is_some());
assert!(
tables.tone_curve.is_none(),
"Adobe Standard has no curve of its own"
);
assert_eq!(raw.baseline_exposure, 0.25);
}
#[test]
+8
View File
@@ -147,6 +147,12 @@ pub struct RawImage {
/// renders a decoded file renders it through the same profile without
/// having to be told — see camera-profiles.md §3.
pub profile_tables: Option<std::sync::Arc<dr_types::ProfileTables>>,
/// TRACES: FR-DEV-3e
/// Stops the render adds before anything else: the file's
/// `BaselineExposure` plus the profile's `BaselineExposureOffset`
/// (camera-profiles.md §11). Applied by the GPU side as a gain on the
/// camera matrix; `color_matrix` itself stays the file's.
pub baseline_exposure: f32,
/// The body, as rawler cleans the names: what `Make`/`Model` say, and
/// what a `.dcp`'s `UniqueCameraModel` is matched against.
pub make: String,
@@ -602,6 +608,7 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
let dcp::Resolved {
profile,
tables: profile_tables,
baseline_exposure,
..
} = dcp::resolve(
profile,
@@ -700,6 +707,7 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
samples_per_pixel,
profile,
profile_tables,
baseline_exposure,
make: image.camera.clean_make.clone(),
model: image.camera.clean_model.clone(),
})