Skip to main content

rawshift_image/transforms/
opcodes.rs

1//! DNG Opcode List processing.
2//!
3//! DNG opcodes are embedded binary records in OpcodeList1/2/3 TIFF tags that
4//! describe image corrections: lens shading (GainMap), bad pixel fixing, etc.
5//!
6//! Binary layout of an OpcodeList (big-endian throughout):
7//! ```text
8//! [4 bytes] count of opcodes
9//! For each opcode:
10//!   [4 bytes] opcode ID
11//!   [4 bytes] minimum DNG SDK version (ignored during parsing)
12//!   [4 bytes] flags  (bit 0 = optional; skip on error if set)
13//!   [4 bytes] parameter data length N
14//!   [N bytes] parameter data
15//! ```
16//!
17//! Implemented opcodes (priority order):
18//! 1. `FixBadPixelsConstant` (ID 4) — replace sub-threshold pixels
19//! 2. `FixBadPixelsList` (ID 5) — replace specific known bad pixels
20//! 3. `GainMap` (ID 9) — spatially-varying lens-shading correction (critical for ProRAW)
21
22use crate::core::image::RgbImage;
23
24// ============================================================================
25// Opcode data structures
26// ============================================================================
27
28/// A parsed DNG GainMap opcode (opcode ID 9).
29///
30/// Encodes a spatially-varying multiplicative gain applied to each colour
31/// channel to correct for lens vignetting / shading.
32#[derive(Debug, Clone)]
33pub struct GainMap {
34    /// Normalised top edge of the region this map covers (0.0–1.0)
35    pub top: f64,
36    /// Normalised left edge
37    pub left: f64,
38    /// Normalised bottom edge
39    pub bottom: f64,
40    /// Normalised right edge
41    pub right: f64,
42    /// Index of the first plane (colour channel) this map applies to
43    pub plane: u32,
44    /// Number of consecutive planes this map applies to
45    pub planes: u32,
46    /// Row sub-sampling pitch (every N rows has a map sample)
47    pub row_pitch: u32,
48    /// Column sub-sampling pitch
49    pub col_pitch: u32,
50    /// Number of map sample rows
51    pub map_points_v: u32,
52    /// Number of map sample columns
53    pub map_points_h: u32,
54    /// Normalised row spacing between consecutive map samples
55    pub map_spacing_v: f64,
56    /// Normalised column spacing
57    pub map_spacing_h: f64,
58    /// Normalised row coordinate of the first map sample
59    pub map_origin_v: f64,
60    /// Normalised column coordinate of the first map sample
61    pub map_origin_h: f64,
62    /// Number of planes stored in the map data (1 = shared, 3 = per-channel)
63    pub map_planes: u32,
64    /// Gain values, stored in [v][h][plane] order (row-major, f32 each)
65    pub gain: Vec<f32>,
66}
67
68impl GainMap {
69    /// Apply this GainMap to an RGB image.
70    ///
71    /// Bilinearly interpolates gain values from the map grid and multiplies
72    /// each affected pixel channel by the corresponding gain.
73    pub fn apply_to_rgb(&self, image: &mut RgbImage) {
74        let img_w = image.width() as usize;
75        let img_h = image.height() as usize;
76        if img_w == 0 || img_h == 0 {
77            return;
78        }
79
80        let map_v = self.map_points_v as usize;
81        let map_h = self.map_points_h as usize;
82        let map_p = self.map_planes as usize;
83        if map_v == 0 || map_h == 0 || map_p == 0 || self.gain.is_empty() {
84            return;
85        }
86
87        let planes_start = self.plane as usize;
88        // `planes` is the number of output channels this map covers.
89        // `map_planes` is how many distinct gain planes the data holds
90        // (1 = shared gain applied to all `planes` output channels).
91        let planes_count = (self.planes as usize).min(3);
92
93        for y in 0..img_h {
94            // Normalised image coordinate in [0, 1]
95            let norm_v = if img_h > 1 {
96                y as f64 / (img_h - 1) as f64
97            } else {
98                0.5
99            };
100
101            // Fractional map row index
102            let map_row_f =
103                ((norm_v - self.map_origin_v) / self.map_spacing_v).clamp(0.0, (map_v - 1) as f64);
104            let r0 = map_row_f.floor() as usize;
105            let r1 = (r0 + 1).min(map_v - 1);
106            let dr = map_row_f - r0 as f64;
107
108            for x in 0..img_w {
109                let norm_h = if img_w > 1 {
110                    x as f64 / (img_w - 1) as f64
111                } else {
112                    0.5
113                };
114
115                let map_col_f = ((norm_h - self.map_origin_h) / self.map_spacing_h)
116                    .clamp(0.0, (map_h - 1) as f64);
117                let c0 = map_col_f.floor() as usize;
118                let c1 = (c0 + 1).min(map_h - 1);
119                let dc = map_col_f - c0 as f64;
120
121                let pixel_base = (y * img_w + x) * 3;
122
123                for p in 0..planes_count {
124                    let channel = planes_start + p;
125                    if channel >= 3 {
126                        break;
127                    }
128                    // When map_planes == 1 the same gain applies to all channels
129                    let mp = if map_p == 1 { 0 } else { p.min(map_p - 1) };
130
131                    let g00 = self.gain[r0 * map_h * map_p + c0 * map_p + mp] as f64;
132                    let g01 = self.gain[r0 * map_h * map_p + c1 * map_p + mp] as f64;
133                    let g10 = self.gain[r1 * map_h * map_p + c0 * map_p + mp] as f64;
134                    let g11 = self.gain[r1 * map_h * map_p + c1 * map_p + mp] as f64;
135
136                    let gain = g00 * (1.0 - dr) * (1.0 - dc)
137                        + g01 * (1.0 - dr) * dc
138                        + g10 * dr * (1.0 - dc)
139                        + g11 * dr * dc;
140
141                    let val = image.data[pixel_base + channel];
142                    image.data[pixel_base + channel] =
143                        (val as f64 * gain).clamp(0.0, 65535.0) as u16;
144                }
145            }
146        }
147    }
148}
149
150/// A parsed DNG opcode.
151#[derive(Debug, Clone)]
152pub enum DngOpcode {
153    /// Opcode ID 4: replace all pixels below a threshold with interpolated neighbours.
154    FixBadPixelsConstant {
155        /// Number of image planes affected
156        planes: u32,
157        /// Pixel value treated as defective
158        bad_point_value: u32,
159    },
160    /// Opcode ID 5: replace a specific list of known bad pixels / rectangles.
161    FixBadPixelsList {
162        /// List of bad pixel (row, col) coordinates
163        bad_points: Vec<(u32, u32)>,
164        /// List of bad pixel regions (top, left, bottom, right)
165        bad_rects: Vec<(u32, u32, u32, u32)>,
166    },
167    /// Opcode ID 9: spatially-varying gain map (lens shading / vignetting correction).
168    GainMap(GainMap),
169    /// An unrecognised opcode — stored so callers can decide whether to skip.
170    Unknown {
171        /// Raw opcode identifier
172        id: u32,
173    },
174}
175
176// ============================================================================
177// OpcodeList
178// ============================================================================
179
180/// A parsed list of DNG opcodes from one of the OpcodeList1/2/3 TIFF tags.
181#[derive(Debug, Clone, Default)]
182pub struct OpcodeList {
183    /// Parsed opcodes together with their `is_optional` flag.
184    ///
185    /// `is_optional == true` means failures should be silently ignored.
186    pub opcodes: Vec<(DngOpcode, bool)>,
187}
188
189impl OpcodeList {
190    /// Parse an opcode list from the raw TIFF tag bytes (UNDEFINED type, big-endian).
191    ///
192    /// Returns an empty list if `data` is shorter than 4 bytes.
193    pub fn parse(data: &[u8]) -> Self {
194        if data.len() < 4 {
195            return OpcodeList::default();
196        }
197
198        let count = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
199        let mut pos = 4;
200        let mut opcodes = Vec::with_capacity(count);
201
202        for _ in 0..count {
203            if pos + 16 > data.len() {
204                break;
205            }
206
207            let opcode_id =
208                u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
209            // skip 4-byte min DNG version at pos+4
210            let flags =
211                u32::from_be_bytes([data[pos + 8], data[pos + 9], data[pos + 10], data[pos + 11]]);
212            let param_len = u32::from_be_bytes([
213                data[pos + 12],
214                data[pos + 13],
215                data[pos + 14],
216                data[pos + 15],
217            ]) as usize;
218            pos += 16;
219
220            let is_optional = (flags & 1) != 0;
221
222            if pos + param_len > data.len() {
223                break;
224            }
225            let param_data = &data[pos..pos + param_len];
226            pos += param_len;
227
228            let opcode = match opcode_id {
229                4 => Self::parse_fix_bad_pixels_constant(param_data),
230                5 => Self::parse_fix_bad_pixels_list(param_data),
231                9 => Self::parse_gain_map(param_data),
232                id => Some(DngOpcode::Unknown { id }),
233            };
234
235            if let Some(op) = opcode {
236                opcodes.push((op, is_optional));
237            }
238        }
239
240        OpcodeList { opcodes }
241    }
242
243    /// Apply this opcode list to an RGB image.
244    ///
245    /// Opcodes are applied in order. Unknown or unimplemented opcodes are
246    /// skipped with a trace-level log. Optional opcodes that fail are silently
247    /// ignored; required ones that fail are also ignored but logged at warn.
248    pub fn apply_to_rgb(&self, image: &mut RgbImage) {
249        for (opcode, is_optional) in &self.opcodes {
250            match opcode {
251                DngOpcode::GainMap(gm) => {
252                    tracing::trace!(
253                        "Applying GainMap: {}x{} grid, {} planes",
254                        gm.map_points_h,
255                        gm.map_points_v,
256                        gm.map_planes
257                    );
258                    gm.apply_to_rgb(image);
259                }
260                DngOpcode::FixBadPixelsConstant { .. } => {
261                    // Not yet implemented for RGB — applies to raw CFA data
262                    if !is_optional {
263                        tracing::debug!("FixBadPixelsConstant on RGB not yet implemented");
264                    }
265                }
266                DngOpcode::FixBadPixelsList { .. } => {
267                    if !is_optional {
268                        tracing::debug!("FixBadPixelsList on RGB not yet implemented");
269                    }
270                }
271                DngOpcode::Unknown { id } => {
272                    if !is_optional {
273                        tracing::warn!("Skipping unknown required DNG opcode ID={}", id);
274                    } else {
275                        tracing::trace!("Skipping unknown optional DNG opcode ID={}", id);
276                    }
277                }
278            }
279        }
280    }
281
282    // -----------------------------------------------------------------------
283    // Private parsers
284    // -----------------------------------------------------------------------
285
286    fn read_u32_be(data: &[u8], offset: usize) -> Option<u32> {
287        data.get(offset..offset + 4)
288            .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
289    }
290
291    fn read_f32_be(data: &[u8], offset: usize) -> Option<f32> {
292        data.get(offset..offset + 4)
293            .map(|b| f32::from_be_bytes([b[0], b[1], b[2], b[3]]))
294    }
295
296    fn read_f64_be(data: &[u8], offset: usize) -> Option<f64> {
297        data.get(offset..offset + 8)
298            .map(|b| f64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]))
299    }
300
301    fn parse_gain_map(data: &[u8]) -> Option<DngOpcode> {
302        // Minimum fixed header: 8 rationals (4+4 bytes each = 32 bytes) +
303        // 6 uint32 (24 bytes) + 4 doubles (32 bytes) + 1 uint32 (4 bytes) = 92 bytes
304        if data.len() < 92 {
305            return None;
306        }
307
308        let top_n = Self::read_u32_be(data, 0)? as f64;
309        let top_d = Self::read_u32_be(data, 4)? as f64;
310        let left_n = Self::read_u32_be(data, 8)? as f64;
311        let left_d = Self::read_u32_be(data, 12)? as f64;
312        let bottom_n = Self::read_u32_be(data, 16)? as f64;
313        let bottom_d = Self::read_u32_be(data, 20)? as f64;
314        let right_n = Self::read_u32_be(data, 24)? as f64;
315        let right_d = Self::read_u32_be(data, 28)? as f64;
316
317        let plane = Self::read_u32_be(data, 32)?;
318        let planes = Self::read_u32_be(data, 36)?;
319        let row_pitch = Self::read_u32_be(data, 40)?;
320        let col_pitch = Self::read_u32_be(data, 44)?;
321        let map_points_v = Self::read_u32_be(data, 48)?;
322        let map_points_h = Self::read_u32_be(data, 52)?;
323        let map_spacing_v = Self::read_f64_be(data, 56)?;
324        let map_spacing_h = Self::read_f64_be(data, 64)?;
325        let map_origin_v = Self::read_f64_be(data, 72)?;
326        let map_origin_h = Self::read_f64_be(data, 80)?;
327        let map_planes = Self::read_u32_be(data, 88)?;
328
329        let gain_count = (map_points_v as usize) * (map_points_h as usize) * (map_planes as usize);
330        let gain_offset = 92;
331
332        if gain_offset + gain_count * 4 > data.len() {
333            return None;
334        }
335
336        let mut gain = Vec::with_capacity(gain_count);
337        for i in 0..gain_count {
338            gain.push(Self::read_f32_be(data, gain_offset + i * 4)?);
339        }
340
341        Some(DngOpcode::GainMap(GainMap {
342            top: if top_d != 0.0 { top_n / top_d } else { 0.0 },
343            left: if left_d != 0.0 { left_n / left_d } else { 0.0 },
344            bottom: if bottom_d != 0.0 {
345                bottom_n / bottom_d
346            } else {
347                1.0
348            },
349            right: if right_d != 0.0 {
350                right_n / right_d
351            } else {
352                1.0
353            },
354            plane,
355            planes,
356            row_pitch,
357            col_pitch,
358            map_points_v,
359            map_points_h,
360            map_spacing_v,
361            map_spacing_h,
362            map_origin_v,
363            map_origin_h,
364            map_planes,
365            gain,
366        }))
367    }
368
369    fn parse_fix_bad_pixels_constant(data: &[u8]) -> Option<DngOpcode> {
370        if data.len() < 8 {
371            return None;
372        }
373        let planes = Self::read_u32_be(data, 0)?;
374        let bad_point_value = Self::read_u32_be(data, 4)?;
375        Some(DngOpcode::FixBadPixelsConstant {
376            planes,
377            bad_point_value,
378        })
379    }
380
381    fn parse_fix_bad_pixels_list(data: &[u8]) -> Option<DngOpcode> {
382        if data.len() < 8 {
383            return None;
384        }
385        let bad_point_count = Self::read_u32_be(data, 0)? as usize;
386        let bad_rect_count = Self::read_u32_be(data, 4)? as usize;
387        let mut offset = 8;
388
389        let mut bad_points = Vec::with_capacity(bad_point_count);
390        for _ in 0..bad_point_count {
391            if offset + 8 > data.len() {
392                return None;
393            }
394            let row = Self::read_u32_be(data, offset)?;
395            let col = Self::read_u32_be(data, offset + 4)?;
396            bad_points.push((row, col));
397            offset += 8;
398        }
399
400        let mut bad_rects = Vec::with_capacity(bad_rect_count);
401        for _ in 0..bad_rect_count {
402            if offset + 16 > data.len() {
403                return None;
404            }
405            let top = Self::read_u32_be(data, offset)?;
406            let left = Self::read_u32_be(data, offset + 4)?;
407            let bottom = Self::read_u32_be(data, offset + 8)?;
408            let right = Self::read_u32_be(data, offset + 12)?;
409            bad_rects.push((top, left, bottom, right));
410            offset += 16;
411        }
412
413        Some(DngOpcode::FixBadPixelsList {
414            bad_points,
415            bad_rects,
416        })
417    }
418}
419
420// ============================================================================
421// Tests
422// ============================================================================
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    /// Build a minimal GainMap opcode list with a single uniform gain.
429    fn build_gain_map_opcode_list(gain_value: f32) -> Vec<u8> {
430        let mut data = Vec::new();
431
432        // count = 1
433        data.extend_from_slice(&1u32.to_be_bytes());
434
435        // opcode_id = 9 (GainMap)
436        data.extend_from_slice(&9u32.to_be_bytes());
437        // min version (ignored)
438        data.extend_from_slice(&0u32.to_be_bytes());
439        // flags = 1 (optional)
440        data.extend_from_slice(&1u32.to_be_bytes());
441
442        // Build GainMap parameter data
443        let mut params: Vec<u8> = Vec::new();
444        // top = 0/1
445        params.extend_from_slice(&0u32.to_be_bytes());
446        params.extend_from_slice(&1u32.to_be_bytes());
447        // left = 0/1
448        params.extend_from_slice(&0u32.to_be_bytes());
449        params.extend_from_slice(&1u32.to_be_bytes());
450        // bottom = 1/1
451        params.extend_from_slice(&1u32.to_be_bytes());
452        params.extend_from_slice(&1u32.to_be_bytes());
453        // right = 1/1
454        params.extend_from_slice(&1u32.to_be_bytes());
455        params.extend_from_slice(&1u32.to_be_bytes());
456        // plane=0, planes=3, row_pitch=1, col_pitch=1
457        params.extend_from_slice(&0u32.to_be_bytes());
458        params.extend_from_slice(&3u32.to_be_bytes());
459        params.extend_from_slice(&1u32.to_be_bytes());
460        params.extend_from_slice(&1u32.to_be_bytes());
461        // map_points_v=2, map_points_h=2
462        params.extend_from_slice(&2u32.to_be_bytes());
463        params.extend_from_slice(&2u32.to_be_bytes());
464        // map_spacing_v=1.0, map_spacing_h=1.0
465        params.extend_from_slice(&1.0f64.to_be_bytes());
466        params.extend_from_slice(&1.0f64.to_be_bytes());
467        // map_origin_v=0.0, map_origin_h=0.0
468        params.extend_from_slice(&0.0f64.to_be_bytes());
469        params.extend_from_slice(&0.0f64.to_be_bytes());
470        // map_planes=1
471        params.extend_from_slice(&1u32.to_be_bytes());
472        // 4 gain samples (2x2 grid, 1 plane)
473        for _ in 0..4 {
474            params.extend_from_slice(&gain_value.to_be_bytes());
475        }
476
477        // param_len
478        data.extend_from_slice(&(params.len() as u32).to_be_bytes());
479        data.extend_from_slice(&params);
480
481        data
482    }
483
484    #[test]
485    fn test_parse_empty() {
486        let list = OpcodeList::parse(&[]);
487        assert!(list.opcodes.is_empty());
488    }
489
490    #[test]
491    fn test_parse_gain_map_uniform() {
492        let data = build_gain_map_opcode_list(2.0);
493        let list = OpcodeList::parse(&data);
494        assert_eq!(list.opcodes.len(), 1);
495        let (op, is_optional) = &list.opcodes[0];
496        assert!(is_optional);
497        match op {
498            DngOpcode::GainMap(gm) => {
499                assert_eq!(gm.map_points_v, 2);
500                assert_eq!(gm.map_points_h, 2);
501                assert_eq!(gm.map_planes, 1);
502                assert_eq!(gm.gain.len(), 4);
503                assert!((gm.gain[0] - 2.0).abs() < 1e-6);
504            }
505            _ => panic!("Expected GainMap opcode"),
506        }
507    }
508
509    #[test]
510    fn test_apply_uniform_gain_doubles_pixel() {
511        let data = build_gain_map_opcode_list(2.0);
512        let list = OpcodeList::parse(&data);
513
514        let mut img = RgbImage::new(2, 2, vec![1000u16; 12]);
515        list.apply_to_rgb(&mut img);
516
517        // Uniform gain of 2.0 should double all pixels
518        for &v in &img.data {
519            assert_eq!(v, 2000, "Expected pixel value 2000, got {v}");
520        }
521    }
522
523    #[test]
524    fn test_parse_fix_bad_pixels_constant() {
525        let mut data = Vec::new();
526        data.extend_from_slice(&1u32.to_be_bytes()); // count=1
527        data.extend_from_slice(&4u32.to_be_bytes()); // id=4
528        data.extend_from_slice(&0u32.to_be_bytes()); // min version
529        data.extend_from_slice(&0u32.to_be_bytes()); // flags=0 (required)
530        let mut params = Vec::new();
531        params.extend_from_slice(&3u32.to_be_bytes()); // planes=3
532        params.extend_from_slice(&0u32.to_be_bytes()); // bad_point_value=0
533        data.extend_from_slice(&(params.len() as u32).to_be_bytes());
534        data.extend_from_slice(&params);
535
536        let list = OpcodeList::parse(&data);
537        assert_eq!(list.opcodes.len(), 1);
538        match &list.opcodes[0].0 {
539            DngOpcode::FixBadPixelsConstant {
540                planes,
541                bad_point_value,
542            } => {
543                assert_eq!(*planes, 3);
544                assert_eq!(*bad_point_value, 0);
545            }
546            _ => panic!("Expected FixBadPixelsConstant"),
547        }
548    }
549
550    #[test]
551    fn test_parse_fix_bad_pixels_list() {
552        let mut data = Vec::new();
553        data.extend_from_slice(&1u32.to_be_bytes()); // count=1
554        data.extend_from_slice(&5u32.to_be_bytes()); // id=5
555        data.extend_from_slice(&0u32.to_be_bytes()); // min version
556        data.extend_from_slice(&1u32.to_be_bytes()); // flags=1 (optional)
557        let mut params = Vec::new();
558        params.extend_from_slice(&1u32.to_be_bytes()); // bad_point_count=1
559        params.extend_from_slice(&0u32.to_be_bytes()); // bad_rect_count=0
560        params.extend_from_slice(&100u32.to_be_bytes()); // row=100
561        params.extend_from_slice(&200u32.to_be_bytes()); // col=200
562        data.extend_from_slice(&(params.len() as u32).to_be_bytes());
563        data.extend_from_slice(&params);
564
565        let list = OpcodeList::parse(&data);
566        assert_eq!(list.opcodes.len(), 1);
567        match &list.opcodes[0].0 {
568            DngOpcode::FixBadPixelsList {
569                bad_points,
570                bad_rects,
571            } => {
572                assert_eq!(bad_points.len(), 1);
573                assert_eq!(bad_points[0], (100, 200));
574                assert!(bad_rects.is_empty());
575            }
576            _ => panic!("Expected FixBadPixelsList"),
577        }
578    }
579
580    #[test]
581    fn test_parse_unknown_opcode() {
582        let mut data = Vec::new();
583        data.extend_from_slice(&1u32.to_be_bytes()); // count=1
584        data.extend_from_slice(&42u32.to_be_bytes()); // unknown id=42
585        data.extend_from_slice(&0u32.to_be_bytes()); // min version
586        data.extend_from_slice(&1u32.to_be_bytes()); // flags=1 (optional)
587        data.extend_from_slice(&0u32.to_be_bytes()); // param_len=0
588
589        let list = OpcodeList::parse(&data);
590        assert_eq!(list.opcodes.len(), 1);
591        match &list.opcodes[0].0 {
592            DngOpcode::Unknown { id } => assert_eq!(*id, 42),
593            _ => panic!("Expected Unknown opcode"),
594        }
595    }
596}