//! 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, pub copyright: Option, pub calibration_signature: Option, /// `ProfileEmbedPolicy`; 0 where absent, as the DNG specification /// defaults it. pub embed_policy: u32, /// `CalibrationIlluminant1/2`, as EXIF light-source codes. pub illuminants: [Option; 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; 2], /// `ProfileLookTableData`. pub look: Option, /// `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>, } 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 { 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), }) } /// TRACES: FR-DEV-3e /// Parse a `.dcp` file's bytes. pub fn parse(bytes: &[u8]) -> Result { 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 { 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()), } } fn temperature(&self, i: usize) -> Option { 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, 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())); } 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::>() .join(" ") .to_lowercase() } fn string(ifd: &IFD, tag: DngTag) -> Option { 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()) } fn floats(value: &Value) -> Option> { (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 { 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)>, } fn library() -> &'static RwLock> { static LIBRARY: OnceLock>> = 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 { library().read().ok()?.as_ref().map(|l| l.dir.clone()) } fn load(dir: &Path) -> Vec<(String, Arc)> { let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); }; let mut out: Vec<(String, Arc)> = 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)> { 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 { 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 { 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()?; Dcp::from_ifd(&root) } /// 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, pub tables: Option>, pub embedded: Option>, } /// 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, root: Option<&IFD>, make: &str, model: &str, ) -> Resolved { let embedded = root.and_then(Dcp::from_ifd).map(Arc::new); 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, 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, }; } } Resolved { profile: matrices, tables: None, embedded: None, } } #[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]), } } #[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.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> { 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()); } #[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()); } }