zenraw 0.1.2

Camera RAW and DNG decoder with zenpixels integration
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! EXIF and DNG metadata extraction.
//!
//! Uses [`kamadak-exif`](https://crates.io/crates/kamadak-exif) for TIFF-based
//! RAW/DNG metadata reading. Provides structured access to camera info, color
//! matrices, and DNG-specific tags.
//!
//! Feature-gated behind `exif`.

extern crate std;

use alloc::string::String;
use alloc::vec::Vec;

use exif::{Context, In, Tag, Value};

/// EXIF metadata extracted from a RAW/DNG file.
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct ExifMetadata {
    // ── Camera info ──
    pub make: Option<String>,
    pub model: Option<String>,
    pub software: Option<String>,
    pub date_time: Option<String>,

    // ── Exposure ──
    pub exposure_time: Option<(u32, u32)>,
    pub f_number: Option<(u32, u32)>,
    pub iso: Option<u32>,
    pub focal_length: Option<(u32, u32)>,
    pub lens_model: Option<String>,

    // ── Image ──
    pub width: Option<u32>,
    pub height: Option<u32>,
    pub orientation: Option<u16>,
    pub bits_per_sample: Option<u16>,

    // ── GPS ──
    pub gps_latitude: Option<f64>,
    pub gps_longitude: Option<f64>,
    pub gps_altitude: Option<f64>,

    // ── DNG-specific ──
    pub dng_version: Option<[u8; 4]>,
    pub unique_camera_model: Option<String>,
    pub color_matrix_1: Option<Vec<f64>>,
    pub color_matrix_2: Option<Vec<f64>>,
    pub forward_matrix_1: Option<Vec<f64>>,
    pub forward_matrix_2: Option<Vec<f64>>,
    pub analog_balance: Option<Vec<f64>>,
    pub as_shot_neutral: Option<Vec<f64>>,
    pub as_shot_white_xy: Option<(f64, f64)>,
    pub baseline_exposure: Option<f64>,
    pub calibration_illuminant_1: Option<u16>,
    pub calibration_illuminant_2: Option<u16>,
}

// ── DNG Tag constants ─────────────────────────────────────────────────

const DNG_VERSION: Tag = Tag(Context::Tiff, 0xC612);
const UNIQUE_CAMERA_MODEL: Tag = Tag(Context::Tiff, 0xC614);
const COLOR_MATRIX_1: Tag = Tag(Context::Tiff, 0xC621);
const COLOR_MATRIX_2: Tag = Tag(Context::Tiff, 0xC622);
const FORWARD_MATRIX_1: Tag = Tag(Context::Tiff, 0xC714);
const FORWARD_MATRIX_2: Tag = Tag(Context::Tiff, 0xC715);
const ANALOG_BALANCE: Tag = Tag(Context::Tiff, 0xC627);
const AS_SHOT_NEUTRAL: Tag = Tag(Context::Tiff, 0xC628);
const AS_SHOT_WHITE_XY: Tag = Tag(Context::Tiff, 0xC629);
const BASELINE_EXPOSURE: Tag = Tag(Context::Tiff, 0xC62A);
const CALIBRATION_ILLUMINANT_1: Tag = Tag(Context::Tiff, 0xC65A);
const CALIBRATION_ILLUMINANT_2: Tag = Tag(Context::Tiff, 0xC65B);

/// Detect Apple AMPF container (JPEG wrapper with HDR gain map).
///
/// iPhone 17 Pro outputs "ProRAW" files in AMPF format: processed JPEG + HDR
/// gain map, NOT linear raw data. These have `.DNG` extension but are NOT DNG.
pub fn is_ampf(data: &[u8]) -> bool {
    // JPEG SOI (FFD8) + JFIF APP0 with AMPF at end of the APP0 segment
    // Layout: FFD8 FFE0 <len:2> "JFIF" <version:2> <units:1> <density:4> <thumb:2> "AMPF"
    // AMPF is at offset 20 in a standard JFIF header
    if data.len() < 24 || data[0] != 0xFF || data[1] != 0xD8 {
        return false;
    }
    // Search first 64 bytes for AMPF marker
    data[..64.min(data.len())].windows(4).any(|w| w == b"AMPF")
}

/// Extract the embedded JPEG preview from a DNG/TIFF file.
///
/// DNG files often contain a reduced-resolution JPEG preview in IFD0
/// (StripOffsets/StripByteCounts). Apple ProRAW (APPLEDNG) files embed a
/// full-resolution sRGB JPEG rendered by the camera pipeline.
///
/// Returns the raw JPEG bytes.
pub fn extract_dng_preview(data: &[u8]) -> Option<Vec<u8>> {
    let exif = exif::Reader::new()
        .read_from_container(&mut std::io::Cursor::new(data))
        .ok()?;

    // Try StripOffsets/StripByteCounts first (most DNGs)
    let (offset, length) = get_strip_preview(&exif).or_else(|| get_thumbnail_preview(&exif))?;

    if offset == 0 || length == 0 || offset + length > data.len() {
        return None;
    }

    let preview = &data[offset..offset + length];

    // Verify it starts with JPEG SOI
    if preview.len() < 2 || preview[0] != 0xFF || preview[1] != 0xD8 {
        return None;
    }

    Some(preview.to_vec())
}

fn get_strip_preview(exif: &exif::Exif) -> Option<(usize, usize)> {
    let off_field = exif.get_field(Tag::StripOffsets, In::PRIMARY)?;
    let len_field = exif.get_field(Tag::StripByteCounts, In::PRIMARY)?;

    let offset = match &off_field.value {
        Value::Long(v) => *v.first()? as usize,
        Value::Short(v) => *v.first()? as usize,
        _ => return None,
    };
    let length = match &len_field.value {
        Value::Long(v) => *v.first()? as usize,
        Value::Short(v) => *v.first()? as usize,
        _ => return None,
    };

    Some((offset, length))
}

fn get_thumbnail_preview(exif: &exif::Exif) -> Option<(usize, usize)> {
    let off_field = exif.get_field(Tag(Context::Tiff, 0x0201), In::PRIMARY)?;
    let len_field = exif.get_field(Tag(Context::Tiff, 0x0202), In::PRIMARY)?;

    let offset = match &off_field.value {
        Value::Long(v) => *v.first()? as usize,
        Value::Short(v) => *v.first()? as usize,
        _ => return None,
    };
    let length = match &len_field.value {
        Value::Long(v) => *v.first()? as usize,
        Value::Short(v) => *v.first()? as usize,
        _ => return None,
    };

    Some((offset, length))
}

/// Extract EXIF and DNG metadata from file bytes.
///
/// Works with TIFF-based RAW formats (DNG, CR2, NEF, ARW, PEF, ORF, etc.)
/// and JPEG files (including Apple AMPF).
pub fn read_metadata(data: &[u8]) -> Option<ExifMetadata> {
    let exif = exif::Reader::new()
        .read_from_container(&mut std::io::Cursor::new(data))
        .ok()?;

    Some(ExifMetadata {
        // Camera info
        make: get_string(&exif, Tag::Make),
        model: get_string(&exif, Tag::Model),
        software: get_string(&exif, Tag::Software),
        date_time: get_string(&exif, Tag::DateTime),

        // Exposure
        exposure_time: get_rational(&exif, Tag::ExposureTime),
        f_number: get_rational(&exif, Tag::FNumber),
        iso: get_u32(&exif, Tag::PhotographicSensitivity),
        focal_length: get_rational(&exif, Tag::FocalLength),
        lens_model: get_string(&exif, Tag::LensModel),

        // Image dimensions
        width: get_u32(&exif, Tag::ImageWidth).or_else(|| get_u32(&exif, Tag::PixelXDimension)),
        height: get_u32(&exif, Tag::ImageLength).or_else(|| get_u32(&exif, Tag::PixelYDimension)),
        orientation: get_u16(&exif, Tag::Orientation),
        bits_per_sample: get_u16(&exif, Tag::BitsPerSample),

        // GPS
        gps_latitude: get_gps_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef),
        gps_longitude: get_gps_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef),
        gps_altitude: get_rational(&exif, Tag::GPSAltitude).map(|(n, d)| n as f64 / d as f64),

        // DNG
        dng_version: get_dng_version(&exif),
        unique_camera_model: get_string(&exif, UNIQUE_CAMERA_MODEL),
        color_matrix_1: get_srational_vec(&exif, COLOR_MATRIX_1),
        color_matrix_2: get_srational_vec(&exif, COLOR_MATRIX_2),
        forward_matrix_1: get_srational_vec(&exif, FORWARD_MATRIX_1),
        forward_matrix_2: get_srational_vec(&exif, FORWARD_MATRIX_2),
        analog_balance: get_rational_vec(&exif, ANALOG_BALANCE),
        as_shot_neutral: get_rational_vec(&exif, AS_SHOT_NEUTRAL),
        as_shot_white_xy: get_rational_xy(&exif, AS_SHOT_WHITE_XY),
        baseline_exposure: get_srational_f64(&exif, BASELINE_EXPOSURE),
        calibration_illuminant_1: get_u16(&exif, CALIBRATION_ILLUMINANT_1),
        calibration_illuminant_2: get_u16(&exif, CALIBRATION_ILLUMINANT_2),
    })
}

// ── Helper functions ──────────────────────────────────────────────────

fn get_string(exif: &exif::Exif, tag: Tag) -> Option<String> {
    let field = exif.get_field(tag, In::PRIMARY)?;
    Some(
        field
            .display_value()
            .to_string()
            .trim_matches('"')
            .to_string(),
    )
}

fn get_u32(exif: &exif::Exif, tag: Tag) -> Option<u32> {
    let field = exif.get_field(tag, In::PRIMARY)?;
    match &field.value {
        Value::Long(v) => v.first().copied(),
        Value::Short(v) => v.first().map(|&x| x as u32),
        _ => None,
    }
}

fn get_u16(exif: &exif::Exif, tag: Tag) -> Option<u16> {
    let field = exif.get_field(tag, In::PRIMARY)?;
    match &field.value {
        Value::Short(v) => v.first().copied(),
        Value::Long(v) => v.first().map(|&x| x as u16),
        _ => None,
    }
}

fn get_rational(exif: &exif::Exif, tag: Tag) -> Option<(u32, u32)> {
    let field = exif.get_field(tag, In::PRIMARY)?;
    match &field.value {
        Value::Rational(v) => v.first().map(|r| (r.num, r.denom)),
        _ => None,
    }
}

fn get_rational_vec(exif: &exif::Exif, tag: Tag) -> Option<Vec<f64>> {
    let field = exif.get_field(tag, In::PRIMARY)?;
    match &field.value {
        Value::Rational(v) => {
            let vals: Vec<f64> = v
                .iter()
                .map(|r| r.num as f64 / r.denom.max(1) as f64)
                .collect();
            if vals.is_empty() { None } else { Some(vals) }
        }
        _ => None,
    }
}

fn get_rational_xy(exif: &exif::Exif, tag: Tag) -> Option<(f64, f64)> {
    let field = exif.get_field(tag, In::PRIMARY)?;
    match &field.value {
        Value::Rational(v) if v.len() >= 2 => {
            let x = v[0].num as f64 / v[0].denom.max(1) as f64;
            let y = v[1].num as f64 / v[1].denom.max(1) as f64;
            Some((x, y))
        }
        _ => None,
    }
}

fn get_srational_vec(exif: &exif::Exif, tag: Tag) -> Option<Vec<f64>> {
    let field = exif.get_field(tag, In::PRIMARY)?;
    match &field.value {
        Value::SRational(v) => {
            let vals: Vec<f64> = v
                .iter()
                .map(|r| r.num as f64 / r.denom.max(1) as f64)
                .collect();
            if vals.is_empty() { None } else { Some(vals) }
        }
        _ => None,
    }
}

fn get_srational_f64(exif: &exif::Exif, tag: Tag) -> Option<f64> {
    let field = exif.get_field(tag, In::PRIMARY)?;
    match &field.value {
        Value::SRational(v) => v.first().map(|r| r.num as f64 / r.denom.max(1) as f64),
        _ => None,
    }
}

fn get_dng_version(exif: &exif::Exif) -> Option<[u8; 4]> {
    let field = exif.get_field(DNG_VERSION, In::PRIMARY)?;
    match &field.value {
        Value::Byte(v) if v.len() >= 4 => Some([v[0], v[1], v[2], v[3]]),
        _ => None,
    }
}

fn get_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<f64> {
    let field = exif.get_field(coord_tag, In::PRIMARY)?;
    let rationals = match &field.value {
        Value::Rational(v) if v.len() >= 3 => v,
        _ => return None,
    };

    let deg = rationals[0].num as f64 / rationals[0].denom.max(1) as f64;
    let min = rationals[1].num as f64 / rationals[1].denom.max(1) as f64;
    let sec = rationals[2].num as f64 / rationals[2].denom.max(1) as f64;
    let mut coord = deg + min / 60.0 + sec / 3600.0;

    // Check reference direction (S/W are negative)
    if let Some(ref_field) = exif.get_field(ref_tag, In::PRIMARY) {
        let s = ref_field.display_value().to_string();
        if s.contains('S') || s.contains('W') {
            coord = -coord;
        }
    }

    Some(coord)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn read_dng_metadata() {
        // Try reading a FiveK DNG
        let dirs = ["/mnt/v/input/fivek/dng/"];
        for dir in &dirs {
            let Ok(entries) = std::fs::read_dir(dir) else {
                continue;
            };
            for entry in entries.filter_map(|e| e.ok()).take(3) {
                let path = entry.path();
                if !path
                    .extension()
                    .is_some_and(|e| e.eq_ignore_ascii_case("dng"))
                {
                    continue;
                }

                let data = std::fs::read(&path).unwrap();
                let meta = read_metadata(&data);
                assert!(
                    meta.is_some(),
                    "failed to read metadata from {}",
                    path.display()
                );

                let meta = meta.unwrap();
                eprintln!("File: {}", path.file_name().unwrap().to_str().unwrap());
                eprintln!("  Make: {:?}", meta.make);
                eprintln!("  Model: {:?}", meta.model);
                eprintln!("  DNG version: {:?}", meta.dng_version);
                eprintln!("  ColorMatrix1: {:?}", meta.color_matrix_1);
                eprintln!("  AsShotNeutral: {:?}", meta.as_shot_neutral);
                eprintln!("  ISO: {:?}", meta.iso);
                eprintln!("  Orientation: {:?}", meta.orientation);

                // DNG files should have DNG version
                assert!(meta.dng_version.is_some());
                // Should have at least a color matrix
                assert!(
                    meta.color_matrix_1.is_some(),
                    "DNG should have ColorMatrix1"
                );
                return; // One successful test is enough
            }
        }
        eprintln!("Skipping: no DNG files found for EXIF test");
    }

    #[test]
    fn extract_appledng_preview() {
        let path = "/mnt/v/heic/46CD6167-C36B-4F98-B386-2300D8E840F0.DNG";
        let Ok(data) = std::fs::read(path) else {
            eprintln!("Skipping: APPLEDNG file not found");
            return;
        };

        let preview = extract_dng_preview(&data);
        assert!(preview.is_some(), "should extract preview from APPLEDNG");
        let preview = preview.unwrap();
        eprintln!("Preview: {} bytes", preview.len());
        assert!(preview.len() > 100_000, "preview should be substantial");
        assert_eq!(preview[0], 0xFF, "should start with JPEG SOI");
        assert_eq!(preview[1], 0xD8, "should start with JPEG SOI");
    }

    #[test]
    fn detect_ampf() {
        let path = "/mnt/v/heic/IMG_3269.DNG";
        let Ok(data) = std::fs::read(path) else {
            eprintln!("Skipping: AMPF file not found");
            return;
        };
        assert!(is_ampf(&data), "should detect AMPF format");

        // APPLEDNG files should NOT be AMPF
        let path2 = "/mnt/v/heic/46CD6167-C36B-4F98-B386-2300D8E840F0.DNG";
        if let Ok(data2) = std::fs::read(path2) {
            assert!(!is_ampf(&data2), "APPLEDNG should not be AMPF");
        }
    }

    #[test]
    fn ampf_metadata() {
        let path = "/mnt/v/heic/IMG_3269.DNG";
        let Ok(data) = std::fs::read(path) else {
            eprintln!("Skipping: AMPF file not found");
            return;
        };

        // kamadak-exif should be able to read JPEG EXIF from AMPF files
        let meta = read_metadata(&data);
        assert!(meta.is_some(), "should read metadata from AMPF");
        let meta = meta.unwrap();
        eprintln!("AMPF: {:?} {:?}", meta.make, meta.model);
        assert_eq!(meta.make.as_deref(), Some("Apple"));
        assert!(meta.model.as_deref().unwrap_or("").contains("iPhone 17"));
    }
}