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.
807 lines
30 KiB
Rust
807 lines
30 KiB
Rust
//! TRACES: FR-DEV-3e
|
|
//! DNG camera profiles: the tables on top of the matrix (D20).
|
|
//!
|
|
//! A profile is what [`crate::profile`] already reads — colour and forward
|
|
//! matrices per calibration illuminant — plus two lookups over HSV: the
|
|
//! `ProfileHueSatMap`, a calibration, and the `ProfileLookTable`, a rendering
|
|
//! intent. `docs/dev/camera-profiles.md` is the design; this module finds
|
|
//! them, in the order its §4 gives:
|
|
//!
|
|
//! 1. embedded in the DNG being decoded ([`Dcp::from_ifd`]);
|
|
//! 2. a `.dcp` file in the profiles directory whose `UniqueCameraModel`
|
|
//! names this body ([`find`]);
|
|
//! 3. nowhere, and the matrix renders alone.
|
|
//!
|
|
//! A `.dcp` is a TIFF whose magic is `RC` (0x4352) rather than 42, holding one
|
|
//! IFD of the same tags a DNG carries. rawler's TIFF reader does not check the
|
|
//! magic, so both sources go through the one parser and [`Dcp::from_ifd`].
|
|
//!
|
|
//! Nothing here applies a table. The lookup is the shader's, with its CPU
|
|
//! reference in `dr-pipeline`; this module resolves *which* tables, and blends
|
|
//! the HueSatMap for the light the frame was shot under, once per decode.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, OnceLock, RwLock};
|
|
|
|
use dr_types::{HueSatTable, ProfileOrigin, ProfileTables};
|
|
use rawler::formats::tiff::{
|
|
DirectoryWriter, GenericTiffReader, SRational, TiffWriter, Value, IFD,
|
|
};
|
|
use rawler::imgop::xyz::Illuminant;
|
|
use rawler::tags::DngTag;
|
|
|
|
use crate::profile::{illuminant_temperature, Calibration, CameraProfile};
|
|
|
|
/// The magic a `.dcp` carries where a TIFF carries 42.
|
|
const DCP_MAGIC: u16 = 0x4352;
|
|
|
|
/// `ProfileEmbedPolicy` values that permit copying a profile out of the file
|
|
/// it came in: 0, "allow copying", and 3, "no restrictions". 1 ("embed if
|
|
/// used") and 2 ("embed never") do not.
|
|
const COPYABLE_POLICIES: [u32; 2] = [0, 3];
|
|
|
|
/// A camera profile as a DNG or a `.dcp` states it.
|
|
///
|
|
/// Indexed `[0]`/`[1]` for calibration 1 and 2, positionally, because that is
|
|
/// how the file pairs a matrix and a table with its illuminant.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Dcp {
|
|
/// `ProfileName`. Empty where the file names none.
|
|
pub name: String,
|
|
/// `UniqueCameraModel`: the body the profile was made for.
|
|
pub unique_camera_model: Option<String>,
|
|
pub copyright: Option<String>,
|
|
pub calibration_signature: Option<String>,
|
|
/// `ProfileEmbedPolicy`; 0 where absent, as the DNG specification
|
|
/// defaults it.
|
|
pub embed_policy: u32,
|
|
/// `CalibrationIlluminant1/2`, as EXIF light-source codes.
|
|
pub illuminants: [Option<u16>; 2],
|
|
/// `ColorMatrix1/2`: XYZ → camera.
|
|
pub color_matrix: [Option<[[f32; 3]; 3]>; 2],
|
|
/// `ForwardMatrix1/2`: white-balanced camera → XYZ (D50).
|
|
pub forward_matrix: [Option<[[f32; 3]; 3]>; 2],
|
|
/// `ProfileHueSatMapData1/2`, sharing one dimensions tag.
|
|
pub hue_sat: [Option<HueSatTable>; 2],
|
|
/// `ProfileLookTableData`.
|
|
pub look: Option<HueSatTable>,
|
|
/// `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 {
|
|
/// TRACES: FR-DEV-3e
|
|
/// Read a profile out of an IFD — a DNG's root, or a `.dcp`'s only one.
|
|
///
|
|
/// `None` where the IFD carries neither table. A DNG always has matrices
|
|
/// and the decoder already reads them; what makes a *profile* worth
|
|
/// carrying separately is a table, so its absence is "no profile" rather
|
|
/// than a profile that says nothing.
|
|
pub fn from_ifd(ifd: &IFD) -> Option<Self> {
|
|
let hue_sat_dims = dims(ifd, DngTag::ProfileHueSatMapDims);
|
|
let hue_sat_srgb = encoding(ifd, DngTag::ProfileHueSatMapEncoding);
|
|
let hue_sat = [DngTag::ProfileHueSatMapData1, DngTag::ProfileHueSatMapData2]
|
|
.map(|tag| hue_sat_dims.and_then(|d| table(ifd, tag, d, hue_sat_srgb)));
|
|
let look = dims(ifd, DngTag::ProfileLookTableDims).and_then(|d| {
|
|
table(
|
|
ifd,
|
|
DngTag::ProfileLookTableData,
|
|
d,
|
|
encoding(ifd, DngTag::ProfileLookTableEncoding),
|
|
)
|
|
});
|
|
if hue_sat[0].is_none() && hue_sat[1].is_none() && look.is_none() {
|
|
return None;
|
|
}
|
|
Some(Self {
|
|
name: string(ifd, DngTag::ProfileName).unwrap_or_default(),
|
|
unique_camera_model: string(ifd, DngTag::UniqueCameraModel),
|
|
copyright: string(ifd, DngTag::ProfileCopyright),
|
|
calibration_signature: string(ifd, DngTag::ProfileCalibrationSignature),
|
|
embed_policy: ifd
|
|
.get_entry(DngTag::ProfileEmbedPolicy)
|
|
.and_then(|e| e.value.get_u32(0).ok().flatten())
|
|
.unwrap_or(0),
|
|
illuminants: [
|
|
DngTag::CalibrationIlluminant1,
|
|
DngTag::CalibrationIlluminant2,
|
|
]
|
|
.map(|tag| {
|
|
ifd.get_entry(tag)
|
|
.and_then(|e| e.value.get_u16(0).ok().flatten())
|
|
}),
|
|
color_matrix: [DngTag::ColorMatrix1, DngTag::ColorMatrix2].map(|t| matrix(ifd, t)),
|
|
forward_matrix: [DngTag::ForwardMatrix1, DngTag::ForwardMatrix2]
|
|
.map(|t| matrix(ifd, t)),
|
|
hue_sat,
|
|
look,
|
|
tone_curve: ifd
|
|
.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),
|
|
})
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// Parse a `.dcp` file's bytes.
|
|
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
|
|
if bytes.len() < 8 {
|
|
return Err("too short to be a camera profile".into());
|
|
}
|
|
let magic = match &bytes[..2] {
|
|
b"II" => u16::from_le_bytes([bytes[2], bytes[3]]),
|
|
b"MM" => u16::from_be_bytes([bytes[2], bytes[3]]),
|
|
_ => return Err("not a TIFF-structured file".into()),
|
|
};
|
|
if magic != DCP_MAGIC {
|
|
return Err(format!("magic {magic:#x} is not a camera profile's"));
|
|
}
|
|
let reader =
|
|
GenericTiffReader::new_with_buffer(bytes, 0, 0, Some(0)).map_err(|e| e.to_string())?;
|
|
use rawler::formats::tiff::reader::TiffReader;
|
|
let profile = Self::from_ifd(reader.root_ifd())
|
|
.ok_or_else(|| "a profile with no HueSatMap and no LookTable".to_string())?;
|
|
if profile.color_matrix[0].is_none() {
|
|
return Err("a profile with no ColorMatrix1".into());
|
|
}
|
|
Ok(profile)
|
|
}
|
|
|
|
/// Whether the file this profile came in allows it to be copied out
|
|
/// (camera-profiles.md §4).
|
|
pub fn may_copy(&self) -> bool {
|
|
COPYABLE_POLICIES.contains(&self.embed_policy)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// Whether this profile was made for the body named.
|
|
///
|
|
/// `unique` is the file's own `UniqueCameraModel`, where a DNG carries
|
|
/// one; `make` and `model` are rawler's cleaned names, joined as Adobe
|
|
/// spells a body ("Canon EOS 6D"). Case and runs of spaces are ignored,
|
|
/// because the two spellings come from different vendors' tables.
|
|
pub fn is_for(&self, unique: Option<&str>, make: &str, model: &str) -> bool {
|
|
let Some(mine) = self.unique_camera_model.as_deref().map(normalise) else {
|
|
return false;
|
|
};
|
|
let joined = if normalise(model).starts_with(&normalise(make)) {
|
|
normalise(model)
|
|
} else {
|
|
normalise(&format!("{make} {model}"))
|
|
};
|
|
unique.map(normalise).as_deref() == Some(mine.as_str()) || joined == mine
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// The matrices this profile was built against, as the decoder's
|
|
/// [`CameraProfile`], with the frame's own as-shot neutral.
|
|
///
|
|
/// A `.dcp` is a whole profile: its tables were measured relative to its
|
|
/// forward matrix, so using them over the file's matrices would apply a
|
|
/// correction for a different starting point. `None` where no calibration
|
|
/// is usable, and the caller keeps the file's.
|
|
pub fn camera_profile(&self, neutral: Option<[f32; 3]>) -> Option<CameraProfile> {
|
|
let calibrations = (0..2)
|
|
.filter_map(|i| {
|
|
let xyz_to_cam = self.color_matrix[i]?;
|
|
let temperature = self.temperature(i)?;
|
|
Some(Calibration {
|
|
temperature,
|
|
xyz_to_cam,
|
|
forward: self.forward_matrix[i],
|
|
})
|
|
})
|
|
.collect();
|
|
CameraProfile::new(calibrations, neutral)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// The tables to render this frame with: the HueSatMap blended for the
|
|
/// scene's colour temperature, by the same mired weight the matrices use,
|
|
/// and the LookTable as it is.
|
|
///
|
|
/// Tables that change nothing are dropped here, so the shader is never
|
|
/// asked to look up an identity.
|
|
pub fn tables(&self, scene_temperature: f32, origin: ProfileOrigin) -> ProfileTables {
|
|
let hue_sat = match (&self.hue_sat, self.temperature(0), self.temperature(1)) {
|
|
([Some(a), Some(b)], Some(ta), Some(tb)) => {
|
|
let t = mired_weight(ta, tb, scene_temperature);
|
|
a.lerp(b, t).or_else(|| Some(a.clone()))
|
|
}
|
|
([Some(a), _], _, _) => Some(a.clone()),
|
|
([None, Some(b)], _, _) => Some(b.clone()),
|
|
([None, None], _, _) => None,
|
|
};
|
|
ProfileTables {
|
|
name: self.name.clone(),
|
|
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),
|
|
}
|
|
}
|
|
|
|
fn temperature(&self, i: usize) -> Option<f32> {
|
|
let code = self.illuminants[i]?;
|
|
let illuminant: Illuminant = code.try_into().ok()?;
|
|
illuminant_temperature(illuminant)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// This profile as `.dcp` bytes, for [`save`].
|
|
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
|
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
|
let writer = TiffWriter::new(&mut cursor).map_err(|e| e.to_string())?;
|
|
let mut dir = DirectoryWriter::new();
|
|
if let Some(model) = &self.unique_camera_model {
|
|
dir.add_tag(DngTag::UniqueCameraModel, model.as_str());
|
|
}
|
|
dir.add_tag(DngTag::ProfileName, self.name.as_str());
|
|
if let Some(c) = &self.copyright {
|
|
dir.add_tag(DngTag::ProfileCopyright, c.as_str());
|
|
}
|
|
if let Some(s) = &self.calibration_signature {
|
|
dir.add_tag(DngTag::ProfileCalibrationSignature, s.as_str());
|
|
}
|
|
dir.add_tag(DngTag::ProfileEmbedPolicy, self.embed_policy);
|
|
let illuminant_tags = [
|
|
DngTag::CalibrationIlluminant1,
|
|
DngTag::CalibrationIlluminant2,
|
|
];
|
|
for (tag, code) in illuminant_tags.into_iter().zip(self.illuminants) {
|
|
if let Some(code) = code {
|
|
dir.add_tag(tag, code);
|
|
}
|
|
}
|
|
for (tag, m) in [DngTag::ColorMatrix1, DngTag::ColorMatrix2]
|
|
.into_iter()
|
|
.zip(self.color_matrix)
|
|
.chain(
|
|
[DngTag::ForwardMatrix1, DngTag::ForwardMatrix2]
|
|
.into_iter()
|
|
.zip(self.forward_matrix),
|
|
)
|
|
{
|
|
if let Some(m) = m {
|
|
dir.add_value(tag, srational_matrix(&m));
|
|
}
|
|
}
|
|
if let Some(first) = self.hue_sat.iter().flatten().next() {
|
|
dir.add_tag(
|
|
DngTag::ProfileHueSatMapDims,
|
|
[
|
|
first.hue_divisions,
|
|
first.sat_divisions,
|
|
first.val_divisions,
|
|
],
|
|
);
|
|
dir.add_tag(
|
|
DngTag::ProfileHueSatMapEncoding,
|
|
u32::from(first.srgb_encoded),
|
|
);
|
|
for (tag, t) in [DngTag::ProfileHueSatMapData1, DngTag::ProfileHueSatMapData2]
|
|
.into_iter()
|
|
.zip(&self.hue_sat)
|
|
{
|
|
if let Some(t) = t {
|
|
dir.add_value(
|
|
tag,
|
|
Value::Float(t.entries.iter().flatten().copied().collect()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if let Some(t) = &self.look {
|
|
dir.add_tag(
|
|
DngTag::ProfileLookTableDims,
|
|
[t.hue_divisions, t.sat_divisions, t.val_divisions],
|
|
);
|
|
dir.add_tag(DngTag::ProfileLookTableEncoding, u32::from(t.srgb_encoded));
|
|
dir.add_value(
|
|
DngTag::ProfileLookTableData,
|
|
Value::Float(t.entries.iter().flatten().copied().collect()),
|
|
);
|
|
}
|
|
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
|
|
// same structure with its own magic in the same place.
|
|
bytes[2..4].copy_from_slice(&DCP_MAGIC.to_ne_bytes());
|
|
Ok(bytes)
|
|
}
|
|
}
|
|
|
|
/// The weight toward calibration 2, by reciprocal temperature — the same
|
|
/// interpolation [`CameraProfile`] gives the matrices, so the tables and the
|
|
/// matrix agree about how far between the two lights a frame was shot.
|
|
fn mired_weight(t1: f32, t2: f32, scene: f32) -> f32 {
|
|
let mired = |k: f32| 1.0e6 / k.max(1.0);
|
|
let (a, b) = (mired(t1), mired(t2));
|
|
if (a - b).abs() < 1e-6 {
|
|
return 0.0;
|
|
}
|
|
((mired(scene) - a) / (b - a)).clamp(0.0, 1.0)
|
|
}
|
|
|
|
fn normalise(s: &str) -> String {
|
|
s.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
.to_lowercase()
|
|
}
|
|
|
|
fn string(ifd: &IFD, tag: DngTag) -> Option<String> {
|
|
ifd.get_entry(tag)
|
|
.and_then(|e| e.value.as_string().cloned())
|
|
.map(|s| s.trim_end_matches('\0').trim().to_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())
|
|
.collect()
|
|
}
|
|
|
|
fn matrix(ifd: &IFD, tag: DngTag) -> Option<[[f32; 3]; 3]> {
|
|
let v = floats(&ifd.get_entry(tag)?.value)?;
|
|
if v.len() != 9 || v.iter().any(|x| !x.is_finite()) {
|
|
return None;
|
|
}
|
|
Some([[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]])
|
|
}
|
|
|
|
fn dims(ifd: &IFD, tag: DngTag) -> Option<[u32; 3]> {
|
|
let e = ifd.get_entry(tag)?;
|
|
let at = |i| e.value.get_u32(i).ok().flatten();
|
|
Some([at(0)?, at(1)?, at(2)?])
|
|
}
|
|
|
|
fn encoding(ifd: &IFD, tag: DngTag) -> bool {
|
|
ifd.get_entry(tag)
|
|
.and_then(|e| e.value.get_u32(0).ok().flatten())
|
|
== Some(1)
|
|
}
|
|
|
|
fn table(ifd: &IFD, tag: DngTag, [h, s, v]: [u32; 3], srgb: bool) -> Option<HueSatTable> {
|
|
let data = floats(&ifd.get_entry(tag)?.value)?;
|
|
if data.len() % 3 != 0 {
|
|
return None;
|
|
}
|
|
let entries = data.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
|
|
HueSatTable::new(h, s, v, srgb, entries)
|
|
}
|
|
|
|
fn srational_matrix(m: &[[f32; 3]; 3]) -> Value {
|
|
const SCALE: i32 = 10_000;
|
|
Value::SRational(
|
|
m.iter()
|
|
.flatten()
|
|
.map(|v| SRational::new((v * SCALE as f32).round() as i32, SCALE))
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
// ---- the profiles directory -------------------------------------------------
|
|
|
|
/// The `.dcp` files the photographer has installed, loaded once per process.
|
|
struct Library {
|
|
dir: PathBuf,
|
|
/// `(file name, profile)`, sorted by file name so that two profiles for
|
|
/// one body resolve the same way on every run (camera-profiles.md §4).
|
|
profiles: Vec<(String, Arc<Dcp>)>,
|
|
}
|
|
|
|
fn library() -> &'static RwLock<Option<Library>> {
|
|
static LIBRARY: OnceLock<RwLock<Option<Library>>> = OnceLock::new();
|
|
LIBRARY.get_or_init(|| RwLock::new(None))
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// Name the profiles directory and read every `.dcp` in it.
|
|
///
|
|
/// Called once at start-up by the application, with a path under the
|
|
/// platform data directory. A decode before this, or in a process that never
|
|
/// calls it (a test, a bench), finds no directory profiles, which is the
|
|
/// matrix-only render it always had.
|
|
pub fn set_profiles_directory(dir: PathBuf) {
|
|
let profiles = load(&dir);
|
|
if let Ok(mut lib) = library().write() {
|
|
*lib = Some(Library { dir, profiles });
|
|
}
|
|
}
|
|
|
|
/// The directory [`set_profiles_directory`] named, if any.
|
|
pub fn profiles_directory() -> Option<PathBuf> {
|
|
library().read().ok()?.as_ref().map(|l| l.dir.clone())
|
|
}
|
|
|
|
fn load(dir: &Path) -> Vec<(String, Arc<Dcp>)> {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return Vec::new();
|
|
};
|
|
let mut out: Vec<(String, Arc<Dcp>)> = entries
|
|
.flatten()
|
|
.filter(|e| {
|
|
e.path()
|
|
.extension()
|
|
.is_some_and(|x| x.eq_ignore_ascii_case("dcp"))
|
|
})
|
|
.filter_map(|e| {
|
|
let name = e.file_name().to_string_lossy().into_owned();
|
|
let bytes = std::fs::read(e.path()).ok()?;
|
|
match Dcp::parse(&bytes) {
|
|
Ok(p) => Some((name, Arc::new(p))),
|
|
Err(why) => {
|
|
log::warn!("camera profile {name} skipped: {why}");
|
|
None
|
|
}
|
|
}
|
|
})
|
|
.collect();
|
|
out.sort_by(|a, b| a.0.cmp(&b.0));
|
|
log::info!(
|
|
"camera profiles: {} loaded from {}",
|
|
out.len(),
|
|
dir.display()
|
|
);
|
|
out
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// The first installed profile, by file name, made for this body.
|
|
pub fn find(unique: Option<&str>, make: &str, model: &str) -> Option<(String, Arc<Dcp>)> {
|
|
let lib = library().read().ok()?;
|
|
lib.as_ref()?
|
|
.profiles
|
|
.iter()
|
|
.find(|(_, p)| p.is_for(unique, make, model))
|
|
.cloned()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// Save a profile copied out of a photograph into the profiles directory, and
|
|
/// make it available to the next decode.
|
|
///
|
|
/// Refuses a profile whose embed policy does not allow copying, and refuses
|
|
/// when no directory is set. Named after the body and the profile, so a
|
|
/// second copy of the same profile replaces the first rather than piling up.
|
|
pub fn save(profile: &Dcp) -> Result<PathBuf, String> {
|
|
if !profile.may_copy() {
|
|
return Err("this profile's embed policy does not allow copying it".into());
|
|
}
|
|
let model = profile
|
|
.unique_camera_model
|
|
.as_deref()
|
|
.ok_or("the profile names no camera")?;
|
|
let dir = profiles_directory().ok_or("no profiles directory is set")?;
|
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
let file_name: String = format!("{model} {}.dcp", profile.name)
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_alphanumeric() || " -_.".contains(c) {
|
|
c
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.collect();
|
|
let path = dir.join(file_name.trim());
|
|
std::fs::write(&path, profile.to_bytes()?).map_err(|e| e.to_string())?;
|
|
set_profiles_directory(dir);
|
|
Ok(path)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// The profile embedded in a file, read on demand — for the panel's offer to
|
|
/// copy it, which happens long after the decode that rendered it.
|
|
///
|
|
/// Reads the header only; no photosite is unpacked.
|
|
pub fn embedded_in(bytes: &[u8]) -> Option<Dcp> {
|
|
let source = rawler::rawsource::RawSource::new_from_slice(bytes);
|
|
let decoder = rawler::get_decoder(&source).ok()?;
|
|
let root = decoder
|
|
.ifd(rawler::decoders::WellKnownIFD::Root)
|
|
.ok()
|
|
.flatten()?;
|
|
// 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
|
|
/// What one decode resolved: the matrices to render through, the tables on
|
|
/// top of them, and the embedded profile if the file had one — kept whole so
|
|
/// the panel can offer to copy it.
|
|
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
|
|
/// Apply camera-profiles.md §4's order to one decoded file.
|
|
///
|
|
/// `matrices` is the profile the decoder built from the file; `root` the
|
|
/// file's root IFD, where a DNG keeps its embedded profile.
|
|
pub fn resolve(
|
|
matrices: Option<CameraProfile>,
|
|
root: Option<&IFD>,
|
|
make: &str,
|
|
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()
|
|
.map(|m| dcp.tables(m.scene_temperature(), ProfileOrigin::Embedded))
|
|
.filter(|t| !t.is_empty())
|
|
.map(Arc::new);
|
|
return Resolved {
|
|
profile: matrices,
|
|
tables,
|
|
baseline_exposure: file_baseline + dcp.baseline_exposure_offset,
|
|
embedded,
|
|
};
|
|
}
|
|
let unique = root.and_then(|r| string(r, DngTag::UniqueCameraModel));
|
|
if let Some((file, dcp)) = find(unique.as_deref(), make, model) {
|
|
let neutral = matrices.as_ref().and_then(|m| m.neutral());
|
|
if let Some(own) = dcp.camera_profile(neutral) {
|
|
let tables = dcp.tables(own.scene_temperature(), ProfileOrigin::File(file));
|
|
return Resolved {
|
|
tables: (!tables.is_empty()).then(|| Arc::new(tables)),
|
|
profile: Some(own),
|
|
embedded: None,
|
|
baseline_exposure: file_baseline + dcp.baseline_exposure_offset,
|
|
};
|
|
}
|
|
}
|
|
Resolved {
|
|
profile: matrices,
|
|
tables: None,
|
|
embedded: None,
|
|
baseline_exposure: file_baseline,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn table(h: u32, s: u32, v: u32, fill: [f32; 3]) -> HueSatTable {
|
|
HueSatTable::new(h, s, v, false, vec![fill; (h * s * v) as usize]).unwrap()
|
|
}
|
|
|
|
fn sample() -> Dcp {
|
|
Dcp {
|
|
name: "Test Standard".into(),
|
|
unique_camera_model: Some("Canon EOS 6D".into()),
|
|
copyright: Some("nobody".into()),
|
|
calibration_signature: Some("com.example".into()),
|
|
embed_policy: 0,
|
|
illuminants: [Some(17), Some(21)],
|
|
color_matrix: [
|
|
Some([
|
|
[0.7546, -0.1435, -0.0929],
|
|
[-0.3846, 1.1488, 0.2692],
|
|
[-0.0332, 0.1209, 0.637],
|
|
]),
|
|
Some([
|
|
[0.7034, -0.0804, -0.1014],
|
|
[-0.442, 1.2564, 0.2058],
|
|
[-0.0851, 0.1994, 0.5758],
|
|
]),
|
|
],
|
|
forward_matrix: [
|
|
Some([
|
|
[0.7763, 0.0065, 0.1815],
|
|
[0.2364, 0.8351, -0.0715],
|
|
[-0.0059, -0.4228, 1.2538],
|
|
]),
|
|
Some([
|
|
[0.7464, 0.1044, 0.1135],
|
|
[0.2648, 0.9173, -0.182],
|
|
[0.0113, -0.2154, 1.0292],
|
|
]),
|
|
],
|
|
hue_sat: [
|
|
Some(table(6, 3, 1, [2.0, 1.1, 1.0])),
|
|
Some(table(6, 3, 1, [-2.0, 0.9, 1.0])),
|
|
],
|
|
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,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_profile_survives_being_written_and_read_back() {
|
|
let original = sample();
|
|
let bytes = original.to_bytes().unwrap();
|
|
assert_eq!(&bytes[2..4], &DCP_MAGIC.to_ne_bytes());
|
|
let back = Dcp::parse(&bytes).unwrap();
|
|
assert_eq!(
|
|
back.hue_sat, original.hue_sat,
|
|
"tables are stored as f32 and come back exact"
|
|
);
|
|
assert_eq!(back.look, original.look);
|
|
assert_eq!(back.name, original.name);
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_tiff_is_not_a_profile() {
|
|
let mut bytes = sample().to_bytes().unwrap();
|
|
bytes[2..4].copy_from_slice(&42u16.to_ne_bytes());
|
|
assert!(Dcp::parse(&bytes).is_err());
|
|
assert!(Dcp::parse(b"nonsense").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_body_matches_by_unique_model_or_by_make_and_model() {
|
|
let p = sample();
|
|
assert!(p.is_for(None, "Canon", "EOS 6D"));
|
|
assert!(p.is_for(None, "canon", "eos 6d"));
|
|
assert!(p.is_for(Some("Canon EOS 6D"), "", ""));
|
|
assert!(!p.is_for(None, "Canon", "EOS 6D Mark II"));
|
|
assert!(!p.is_for(Some("Canon EOS 5D"), "Canon", "EOS 5D"));
|
|
// A model that already starts with the make is not doubled.
|
|
assert!(p.is_for(None, "Canon", "Canon EOS 6D"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_hue_sat_map_follows_the_light_the_frame_was_shot_under() {
|
|
let p = sample();
|
|
let at = |k| {
|
|
p.tables(k, ProfileOrigin::Embedded)
|
|
.hue_sat
|
|
.unwrap()
|
|
.entries[0]
|
|
};
|
|
assert_eq!(at(2856.0), [2.0, 1.1, 1.0], "tungsten is calibration 1");
|
|
assert_eq!(at(6504.0), [-2.0, 0.9, 1.0], "daylight is calibration 2");
|
|
let mid = at(4000.0);
|
|
assert!(mid[0] > -2.0 && mid[0] < 2.0, "{mid:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_table_that_changes_nothing_is_not_handed_on() {
|
|
let mut p = sample();
|
|
p.hue_sat = [Some(table(6, 3, 1, [0.0, 1.0, 1.0])), None];
|
|
let t = p.tables(5000.0, ProfileOrigin::Embedded);
|
|
assert!(t.hue_sat.is_none());
|
|
assert!(t.look.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn only_a_copyable_policy_may_be_copied() {
|
|
let mut p = sample();
|
|
for (policy, ok) in [(0, true), (1, false), (2, false), (3, true)] {
|
|
p.embed_policy = policy;
|
|
assert_eq!(p.may_copy(), ok, "policy {policy}");
|
|
}
|
|
}
|
|
|
|
/// A Canon 6D DNG from the library, written by Lightroom 6.14 with Adobe
|
|
/// Standard embedded. Read from `DR_DCP_SAMPLE`, else the library path the
|
|
/// figures in camera-profiles.md §1 came from; skipped where neither
|
|
/// exists, because the file is not ours to put in the repository.
|
|
fn six_d_dng() -> Option<Vec<u8>> {
|
|
let path = std::env::var_os("DR_DCP_SAMPLE")
|
|
.map(PathBuf::from)
|
|
.or_else(|| {
|
|
std::env::var_os("HOME").map(|h| {
|
|
PathBuf::from(h).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng")
|
|
})
|
|
})?;
|
|
let bytes = std::fs::read(&path).ok();
|
|
if bytes.is_none() {
|
|
eprintln!("skipped: no sample DNG at {}", path.display());
|
|
}
|
|
bytes
|
|
}
|
|
|
|
#[test]
|
|
fn the_libraries_six_d_dngs_carry_adobe_standard() {
|
|
let Some(bytes) = six_d_dng() else { return };
|
|
let p = embedded_in(&bytes).expect("an embedded profile");
|
|
assert_eq!(p.name, "Adobe Standard");
|
|
assert_eq!(p.unique_camera_model.as_deref(), Some("Canon EOS 6D"));
|
|
assert_eq!(p.embed_policy, 0);
|
|
let hs = p.hue_sat[0].as_ref().unwrap();
|
|
assert_eq!(
|
|
(hs.hue_divisions, hs.sat_divisions, hs.val_divisions),
|
|
(90, 30, 1)
|
|
);
|
|
assert!(p.hue_sat[1].is_some());
|
|
let look = p.look.as_ref().unwrap();
|
|
assert_eq!(
|
|
(look.hue_divisions, look.sat_divisions, look.val_divisions),
|
|
(36, 8, 16)
|
|
);
|
|
assert!(p.tone_curve.is_none());
|
|
assert!(p.may_copy());
|
|
|
|
let back = Dcp::parse(&p.to_bytes().unwrap()).unwrap();
|
|
assert_eq!(
|
|
back.hue_sat, p.hue_sat,
|
|
"a copied profile keeps its tables bit for bit"
|
|
);
|
|
assert_eq!(back.look, p.look);
|
|
assert!(
|
|
back.is_for(None, "Canon", "EOS 6D"),
|
|
"and so applies to the body's CR2s"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decoding_the_six_d_dng_hands_on_its_tables() {
|
|
let Some(bytes) = six_d_dng() else { return };
|
|
let raw = crate::decode(&bytes).unwrap();
|
|
let tables = raw.profile_tables.expect("tables");
|
|
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]
|
|
fn a_profile_brings_its_own_matrices() {
|
|
let p = sample();
|
|
let cam = p.camera_profile(Some([0.5, 1.0, 0.7])).unwrap();
|
|
assert_eq!(cam.calibrations().len(), 2);
|
|
assert!(cam.calibrations().iter().all(|c| c.forward.is_some()));
|
|
assert!(cam.cam_to_srgb().is_some());
|
|
}
|
|
}
|