Skip to main content

flashraw_core/
camera_profile.rs

1//! DNG camera profiles: the colour matrices, hue/saturation/value tables and tone curve of
2//! a DNG Camera Profile (`.dcp` file) or of the profile tags embedded in a DNG.
3//!
4//! This module only holds and parses the data; the `flashraw` crate renders with it. A DCP is
5//! a TIFF-like file with the magic `IIRC` in place of the TIFF version, always little-endian,
6//! whose single IFD uses the DNG profile tags.
7
8use crate::tiff::{Ifd, Tiff, TiffOptions, tags};
9use crate::types::{ColorMatrix, Illuminant};
10use crate::{Error, Result};
11
12/// Tags that exist only in the DNG tag range and are not yet in [`tags`].
13mod extra {
14    pub const PROFILE_COPYRIGHT: u16 = 0xC6FE;
15    pub const PROFILE_HUE_SAT_MAP_ENCODING: u16 = 0xC7A3;
16    pub const PROFILE_LOOK_TABLE_ENCODING: u16 = 0xC7A4;
17    pub const DEFAULT_BLACK_RENDER: u16 = 0xC7A6;
18}
19
20/// Most table entries accepted (hue x sat x val divisions).
21const MAX_TABLE_ENTRIES: usize = 1 << 22;
22/// Most tone curve points accepted.
23const MAX_CURVE_POINTS: usize = 1 << 14;
24
25/// How the RGB values are encoded before a table converts them to HSV
26/// (`ProfileHueSatMapEncoding`, `ProfileLookTableEncoding`).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29#[non_exhaustive]
30pub enum TableEncoding {
31    /// Linear (the default).
32    #[default]
33    Linear,
34    /// The sRGB transfer curve: values are encoded before the HSV conversion and decoded
35    /// after the table, which spaces the value dimension perceptually.
36    Srgb,
37}
38
39/// A hue/saturation/value table (`ProfileHueSatMapData`, `ProfileLookTableData`).
40///
41/// Entries are `(hue shift in degrees, saturation scale, value scale)` triples, stored with
42/// the saturation division varying fastest, then the hue division, then the value division.
43#[derive(Debug, Clone, PartialEq)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45#[non_exhaustive]
46pub struct ProfileTable {
47    /// Divisions `[hue, saturation, value]`, each at least 1.
48    pub dims: [u32; 3],
49    /// First table: `3 * hue * sat * val` floats. For a hue/sat map this is the table for
50    /// the first calibration illuminant.
51    pub data: Vec<f32>,
52    /// Hue/sat maps only: the table for the second illuminant (same dimensions).
53    pub data2: Option<Vec<f32>>,
54    /// Encoding of the RGB values the table is indexed by.
55    pub encoding: TableEncoding,
56}
57
58impl ProfileTable {
59    /// A table with its first data set. `None` unless `data.len() == 3 * dims product`, every
60    /// dimension is at least 1, the table is not absurdly large and every value is finite.
61    pub fn new(dims: [u32; 3], data: Vec<f32>, encoding: TableEncoding) -> Option<Self> {
62        let n = dims.iter().try_fold(1usize, |a, &d| a.checked_mul(d as usize))?;
63        let ok = dims.iter().all(|&d| d >= 1) && n <= MAX_TABLE_ENTRIES / 3 && data.len() == n * 3;
64        (ok && data.iter().all(|v| v.is_finite())).then_some(ProfileTable { dims, data, data2: None, encoding })
65    }
66
67    /// Number of `(hue, sat, val)` entries per data set.
68    pub fn len(&self) -> usize {
69        self.data.len() / 3
70    }
71
72    /// Whether the table has no entries (never true for a validated table).
73    pub fn is_empty(&self) -> bool {
74        self.data.is_empty()
75    }
76}
77
78/// A DNG camera profile.
79///
80/// Build one from a `.dcp` file ([`CameraProfile::from_dcp`]) or take the one embedded in a
81/// DNG (`FlashRaw::embedded_camera_profile` in the `flashraw` crate). Every field is optional
82/// data; a profile with neither matrices nor tables does nothing.
83#[derive(Debug, Clone, PartialEq, Default)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85#[non_exhaustive]
86pub struct CameraProfile {
87    /// ProfileName.
88    pub name: Option<String>,
89    /// UniqueCameraModel: the camera the profile was made for.
90    pub unique_camera_model: Option<String>,
91    /// ProfileCopyright.
92    pub copyright: Option<String>,
93    /// ProfileEmbedPolicy (0 allow copying, 1 embed if used, 2 embed never, 3 no restrictions).
94    pub embed_policy: Option<u32>,
95    /// ColorMatrix1/2 (XYZ to camera, 3 x 3) with their calibration illuminants.
96    pub color_matrices: Vec<ColorMatrix>,
97    /// ForwardMatrix1/2 (white balanced camera to XYZ D50, 3 x 3) with their illuminants.
98    pub forward_matrices: Vec<ColorMatrix>,
99    /// ProfileHueSatMapDims / ProfileHueSatMapData1/2 / ProfileHueSatMapEncoding.
100    pub hue_sat_map: Option<ProfileTable>,
101    /// ProfileLookTableDims / ProfileLookTableData / ProfileLookTableEncoding.
102    pub look_table: Option<ProfileTable>,
103    /// ProfileToneCurve as `(input, output)` points in `[0, 1]`, ascending in input.
104    pub tone_curve: Option<Vec<[f32; 2]>>,
105    /// BaselineExposureOffset in stops.
106    pub baseline_exposure_offset: f64,
107    /// DefaultBlackRender (0 auto, 1 none). Recorded, not applied.
108    pub default_black_render: u32,
109}
110
111impl CameraProfile {
112    /// Parse a DNG Camera Profile (`.dcp`) file.
113    ///
114    /// # Errors
115    /// [`Error::UnsupportedFormat`] without the `IIRC` magic, [`Error::InvalidMetadata`] for
116    /// tables whose size does not match their dimensions or contain non-finite values.
117    pub fn from_dcp(bytes: &[u8]) -> Result<Self> {
118        if bytes.len() < 8 || &bytes[0..4] != b"IIRC" {
119            return Err(Error::UnsupportedFormat("not a DNG camera profile (missing IIRC magic)".into()));
120        }
121        let tiff = Tiff::parse_with(bytes, TiffOptions { follow_sub_ifds: false, ..TiffOptions::default() })?;
122        let ifd = tiff.ifds.first().ok_or_else(|| Error::corrupt("camera profile without an IFD"))?;
123        let p = Self::from_ifd(ifd, true)?;
124        if p.is_empty() {
125            return Err(Error::invalid_metadata("camera profile without matrices, tables or a tone curve"));
126        }
127        Ok(p)
128    }
129
130    /// The profile tags of a DNG's first IFD. Returns `None` when the file carries no profile
131    /// data (no forward matrix, hue/sat map, look table or tone curve);
132    /// ordinary colour matrices alone do not make a profile. Malformed tables are skipped.
133    pub fn from_dng_ifd(ifd: &Ifd) -> Option<Self> {
134        let p = Self::from_ifd(ifd, false).ok()?;
135        let has_profile_data = !p.forward_matrices.is_empty()
136            || p.hue_sat_map.is_some()
137            || p.look_table.is_some()
138            || p.tone_curve.is_some();
139        has_profile_data.then_some(p)
140    }
141
142    fn from_ifd(ifd: &Ifd, strict: bool) -> Result<Self> {
143        let mut p = CameraProfile {
144            name: ifd.string(tags::PROFILE_NAME),
145            unique_camera_model: ifd.string(tags::UNIQUE_CAMERA_MODEL),
146            copyright: ifd.string(extra::PROFILE_COPYRIGHT),
147            embed_policy: ifd.u32(tags::PROFILE_EMBED_POLICY),
148            baseline_exposure_offset: ifd.f64(tags::BASELINE_EXPOSURE_OFFSET).filter(|v| v.is_finite()).unwrap_or(0.0),
149            default_black_render: ifd.u32(extra::DEFAULT_BLACK_RENDER).unwrap_or(0),
150            ..Default::default()
151        };
152        let illuminant = |tag: u16| Illuminant(ifd.u32(tag).unwrap_or(0) as u16);
153        for (m, i) in
154            [(tags::COLOR_MATRIX1, tags::CALIBRATION_ILLUMINANT1), (tags::COLOR_MATRIX2, tags::CALIBRATION_ILLUMINANT2)]
155        {
156            if let Some(e) = ifd.get(m) {
157                let v = e.f32s();
158                if v.len() == 9 && v.iter().all(|x| x.is_finite()) {
159                    p.color_matrices.extend(ColorMatrix::new(illuminant(i), 3, v));
160                }
161            }
162        }
163        for (m, i) in [
164            (tags::FORWARD_MATRIX1, tags::CALIBRATION_ILLUMINANT1),
165            (tags::FORWARD_MATRIX2, tags::CALIBRATION_ILLUMINANT2),
166        ] {
167            if let Some(e) = ifd.get(m) {
168                let v = e.f32s();
169                if v.len() == 9 && v.iter().all(|x| x.is_finite()) {
170                    p.forward_matrices.extend(ColorMatrix::new(illuminant(i), 3, v));
171                }
172            }
173        }
174        let encoding = |tag: u16| match ifd.u32(tag) {
175            Some(1) => TableEncoding::Srgb,
176            _ => TableEncoding::Linear,
177        };
178        let dims = |tag: u16| -> Option<[u32; 3]> {
179            let v = ifd.get(tag)?.u32s();
180            (v.len() >= 3).then(|| [v[0], v[1], v[2]])
181        };
182        let mut bad = None;
183        if let (Some(d), Some(e1)) = (dims(tags::PROFILE_HUE_SAT_MAP_DIMS), ifd.get(tags::PROFILE_HUE_SAT_MAP_DATA1)) {
184            let enc = encoding(extra::PROFILE_HUE_SAT_MAP_ENCODING);
185            match ProfileTable::new(d, e1.f32s(), enc) {
186                Some(mut t) => {
187                    if let Some(e2) = ifd.get(tags::PROFILE_HUE_SAT_MAP_DATA2) {
188                        let v = e2.f32s();
189                        if v.len() == t.data.len() && v.iter().all(|x| x.is_finite()) {
190                            t.data2 = Some(v);
191                        } else {
192                            bad = Some("ProfileHueSatMapData2 does not match ProfileHueSatMapDims");
193                        }
194                    }
195                    p.hue_sat_map = Some(t);
196                }
197                None => bad = Some("ProfileHueSatMapData1 does not match ProfileHueSatMapDims"),
198            }
199        }
200        if let (Some(d), Some(e)) = (dims(tags::PROFILE_LOOK_TABLE_DIMS), ifd.get(tags::PROFILE_LOOK_TABLE_DATA)) {
201            match ProfileTable::new(d, e.f32s(), encoding(extra::PROFILE_LOOK_TABLE_ENCODING)) {
202                Some(t) => p.look_table = Some(t),
203                None => bad = Some("ProfileLookTableData does not match ProfileLookTableDims"),
204            }
205        }
206        if let Some(e) = ifd.get(tags::PROFILE_TONE_CURVE) {
207            let v = e.f32s();
208            let pts: Vec<[f32; 2]> = v.chunks_exact(2).map(|c| [c[0], c[1]]).collect();
209            let sane = pts.len() >= 2
210                && pts.len() <= MAX_CURVE_POINTS
211                && v.len() % 2 == 0
212                && pts.iter().all(|c| c[0].is_finite() && c[1].is_finite())
213                && pts.windows(2).all(|w| w[1][0] > w[0][0]);
214            if sane {
215                p.tone_curve = Some(pts);
216            } else {
217                bad = Some("ProfileToneCurve is not an ascending list of (input, output) pairs");
218            }
219        }
220        match (bad, strict) {
221            (Some(why), true) => Err(Error::invalid_metadata(why)),
222            _ => Ok(p),
223        }
224    }
225
226    /// Whether the profile carries nothing to apply.
227    pub fn is_empty(&self) -> bool {
228        self.color_matrices.is_empty()
229            && self.forward_matrices.is_empty()
230            && self.hue_sat_map.is_none()
231            && self.look_table.is_none()
232            && self.tone_curve.is_none()
233            && self.baseline_exposure_offset == 0.0
234    }
235
236    /// Serialise as a `.dcp` file (little-endian TIFF with the `IIRC` magic), the inverse of
237    /// [`CameraProfile::from_dcp`]. Used by tests and tools; matrices are written as floats.
238    pub fn to_dcp(&self) -> Vec<u8> {
239        type Entry = (u16, u16, u32, Vec<u8>);
240        fn floats(e: &mut Vec<Entry>, tag: u16, v: &[f32]) {
241            e.push((tag, 11, v.len() as u32, v.iter().flat_map(|x| x.to_le_bytes()).collect()));
242        }
243        fn longs(e: &mut Vec<Entry>, tag: u16, v: &[u32]) {
244            e.push((tag, 4, v.len() as u32, v.iter().flat_map(|x| x.to_le_bytes()).collect()));
245        }
246        fn ascii(e: &mut Vec<Entry>, tag: u16, s: &Option<String>) {
247            if let Some(s) = s {
248                let mut b = s.as_bytes().to_vec();
249                b.push(0);
250                e.push((tag, 2, b.len() as u32, b));
251            }
252        }
253        let mut entries: Vec<Entry> = Vec::new();
254        ascii(&mut entries, tags::UNIQUE_CAMERA_MODEL, &self.unique_camera_model);
255        ascii(&mut entries, tags::PROFILE_NAME, &self.name);
256        ascii(&mut entries, extra::PROFILE_COPYRIGHT, &self.copyright);
257        let color = [tags::COLOR_MATRIX1, tags::COLOR_MATRIX2];
258        let forward = [tags::FORWARD_MATRIX1, tags::FORWARD_MATRIX2];
259        let illuminants = [tags::CALIBRATION_ILLUMINANT1, tags::CALIBRATION_ILLUMINANT2];
260        for (i, m) in self.color_matrices.iter().take(2).enumerate() {
261            floats(&mut entries, color[i], &m.values);
262            entries.push((illuminants[i], 3, 1, m.illuminant.0.to_le_bytes().to_vec()));
263        }
264        for (i, m) in self.forward_matrices.iter().take(2).enumerate() {
265            floats(&mut entries, forward[i], &m.values);
266            if self.color_matrices.len() <= i {
267                entries.push((illuminants[i], 3, 1, m.illuminant.0.to_le_bytes().to_vec()));
268            }
269        }
270        if let Some(t) = &self.hue_sat_map {
271            longs(&mut entries, tags::PROFILE_HUE_SAT_MAP_DIMS, &t.dims);
272            floats(&mut entries, tags::PROFILE_HUE_SAT_MAP_DATA1, &t.data);
273            if let Some(d2) = &t.data2 {
274                floats(&mut entries, tags::PROFILE_HUE_SAT_MAP_DATA2, d2);
275            }
276            if t.encoding == TableEncoding::Srgb {
277                longs(&mut entries, extra::PROFILE_HUE_SAT_MAP_ENCODING, &[1]);
278            }
279        }
280        if let Some(t) = &self.look_table {
281            longs(&mut entries, tags::PROFILE_LOOK_TABLE_DIMS, &t.dims);
282            floats(&mut entries, tags::PROFILE_LOOK_TABLE_DATA, &t.data);
283            if t.encoding == TableEncoding::Srgb {
284                longs(&mut entries, extra::PROFILE_LOOK_TABLE_ENCODING, &[1]);
285            }
286        }
287        if let Some(c) = &self.tone_curve {
288            let flat: Vec<f32> = c.iter().flatten().copied().collect();
289            floats(&mut entries, tags::PROFILE_TONE_CURVE, &flat);
290        }
291        if let Some(e) = self.embed_policy {
292            longs(&mut entries, tags::PROFILE_EMBED_POLICY, &[e]);
293        }
294        if self.baseline_exposure_offset != 0.0 {
295            entries.push((tags::BASELINE_EXPOSURE_OFFSET, 12, 1, self.baseline_exposure_offset.to_le_bytes().to_vec()));
296        }
297        if self.default_black_render != 0 {
298            longs(&mut entries, extra::DEFAULT_BLACK_RENDER, &[self.default_black_render]);
299        }
300        entries.sort_by_key(|e| e.0);
301        let n = entries.len();
302        let mut out = b"IIRC\x08\0\0\0".to_vec();
303        out.extend((n as u16).to_le_bytes());
304        let mut extra_off = 8 + 2 + n * 12 + 4;
305        let mut extra: Vec<u8> = Vec::new();
306        for (tag, typ, count, data) in &entries {
307            out.extend(tag.to_le_bytes());
308            out.extend(typ.to_le_bytes());
309            out.extend(count.to_le_bytes());
310            if data.len() <= 4 {
311                let mut d = data.clone();
312                d.resize(4, 0);
313                out.extend(d);
314            } else {
315                out.extend((extra_off as u32).to_le_bytes());
316                extra_off += data.len();
317                extra.extend(data);
318            }
319        }
320        out.extend(0u32.to_le_bytes());
321        out.extend(extra);
322        out
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    fn sample() -> CameraProfile {
331        let mut hs = ProfileTable::new([2, 2, 1], (0..12).map(|i| i as f32 * 0.5).collect(), TableEncoding::Linear)
332            .expect("table");
333        hs.data2 = Some((0..12).map(|i| i as f32).collect());
334        CameraProfile {
335            name: Some("Test profile".into()),
336            unique_camera_model: Some("Test Camera".into()),
337            copyright: Some("CC0".into()),
338            embed_policy: Some(3),
339            color_matrices: vec![
340                ColorMatrix::new(Illuminant::STANDARD_A, 3, vec![1.0, 0.1, 0.0, 0.0, 1.0, 0.1, 0.1, 0.0, 1.0]).unwrap(),
341                ColorMatrix::new(Illuminant::D65, 3, vec![0.9, 0.0, 0.1, 0.0, 0.9, 0.1, 0.0, 0.1, 0.9]).unwrap(),
342            ],
343            forward_matrices: vec![
344                ColorMatrix::new(Illuminant::STANDARD_A, 3, vec![0.8, 0.1, 0.1, 0.3, 0.6, 0.1, 0.0, 0.1, 0.7]).unwrap(),
345                ColorMatrix::new(Illuminant::D65, 3, vec![0.7, 0.2, 0.1, 0.3, 0.7, 0.0, 0.0, 0.0, 0.8]).unwrap(),
346            ],
347            hue_sat_map: Some(hs),
348            look_table: Some(
349                ProfileTable::new([1, 2, 2], (0..12).map(|i| i as f32 + 0.25).collect(), TableEncoding::Srgb).unwrap(),
350            ),
351            tone_curve: Some(vec![[0.0, 0.0], [0.5, 0.7], [1.0, 1.0]]),
352            baseline_exposure_offset: 0.5,
353            default_black_render: 1,
354        }
355    }
356
357    #[test]
358    fn dcp_round_trips() {
359        let p = sample();
360        let bytes = p.to_dcp();
361        assert_eq!(&bytes[..4], b"IIRC");
362        let q = CameraProfile::from_dcp(&bytes).expect("parse");
363        assert_eq!(q, p);
364    }
365
366    #[test]
367    fn dcp_rejects_other_files() {
368        let p = sample().to_dcp();
369        let mut tiff = p.clone();
370        tiff[..4].copy_from_slice(b"II*\0");
371        assert!(matches!(CameraProfile::from_dcp(&tiff), Err(Error::UnsupportedFormat(_))));
372        assert!(CameraProfile::from_dcp(b"IIRC").is_err());
373        // Truncations never panic.
374        for n in 0..p.len() {
375            let _ = CameraProfile::from_dcp(&p[..n]);
376        }
377    }
378
379    #[test]
380    fn mismatched_table_is_an_error() {
381        let mut p = sample();
382        p.hue_sat_map.as_mut().unwrap().dims = [3, 3, 3];
383        // Written with dimensions that do not match the data.
384        let err = CameraProfile::from_dcp(&p.to_dcp()).unwrap_err();
385        assert!(err.to_string().contains("ProfileHueSatMapData1"), "{err}");
386    }
387
388    #[test]
389    fn table_validation() {
390        assert!(ProfileTable::new([0, 1, 1], vec![], TableEncoding::Linear).is_none());
391        assert!(ProfileTable::new([1, 1, 1], vec![0.0, 1.0], TableEncoding::Linear).is_none());
392        assert!(ProfileTable::new([1, 1, 1], vec![0.0, f32::NAN, 1.0], TableEncoding::Linear).is_none());
393        assert!(ProfileTable::new([u32::MAX, u32::MAX, 2], vec![0.0; 3], TableEncoding::Linear).is_none());
394        assert!(ProfileTable::new([1, 1, 1], vec![0.0, 1.0, 1.0], TableEncoding::Linear).is_some());
395    }
396}