Skip to main content

vsf/
spectral_image.rs

1//! VSF-Image v0 — spectral-first raw image container. The DNG-shaped problem solved forward: an image is K channels of sensor counts, and each channel carries its own spectral sensitivity curve. A Bayer RAW is K=3 with camera curves; an LED multispectral composite is K=25 with LED×sensor product curves; RGB under CIE 1931 is a *rendering* resolved at read/export time, never the storage model. Store the question, not the answer.
2//!
3//! Sections written by [`write`] and read back by [`read`]:
4//! - `spectral_image` — dims, layout (`mosaic`/`planar`), per-channel black/white, CFA tile (mosaic only), make/model, channel names, and the sensor counts as a [`BitPackedTensor`] at native bit depth (shape `[h, w]` for mosaic, `[k, h, w]` for planar).
5//! - `spectral_response` — per-channel sensitivity curves, self-describing grid: `curve_start`/`curve_step`/`curve_counts` per channel plus concatenated `curve_values`. 1nm 350–1100 is the convention, not the law. A channel with count 0 is uncharacterized (present but unmeasured — legacy ingest lands here until a target scan characterizes the device). Section omitted entirely when no channel is characterized.
6//! - `provenance` — identity ingredients per the ihi scheme: `handle` (omitted on the wire when empty — "" is the reserved omission value and what an absent field reads back as), `calibration_hash`, `camera_ihi`, `identity`. The composed identity `spaghettify(sensor_plane_hash || camera_calibration_hash || eagle_time || handle_proof || camera_ihi)` is computed by callers that link ihi; this crate just carries the bytes. The VSF header's own BLAKE3 provenance/rolling hashes cover file integrity as always.
7//! - `colour_profile` — tiered camera→VSF-RGB characterization (parallel arrays, best entry first) plus the raw questions each answers: verbatim DNG matrices, magic-9 solve patches, calibration provenance. Elected under `spectral_response` (curves always win). Section omitted when uncharacterized. See [`ColourProfile`].
8//! - `view_transform` — the translateration log: ordered, named, parameterized view ops (v0: `exposure`) with their IDT class, replayed by downstream tools. The image data is never touched. Section omitted when empty. See [`ViewTransform`].
9
10use crate::decoding::parse::parse;
11use crate::file_format::{VsfField, VsfHeader};
12use crate::prelude::*;
13use crate::types::tensor::{BitPackedTensor, Tensor};
14use crate::types::VsfType;
15use crate::vsf_builder::VsfBuilder;
16
17/// A spectral sensitivity curve on a self-describing wavelength grid: value `i` is the channel's relative response at `start_nm + i * step_nm`.
18#[derive(Debug, Clone, PartialEq)]
19pub struct SpectralCurve {
20    pub start_nm: f32,
21    pub step_nm: f32,
22    pub values: Vec<f32>,
23}
24
25/// One measurement channel: a name for humans and, when the device has been characterized, the spectral response that gives its counts physical meaning. `curve: None` = uncharacterized.
26#[derive(Debug, Clone, PartialEq)]
27pub struct SpectralChannel {
28    pub name: String,
29    pub curve: Option<SpectralCurve>,
30}
31
32/// How the sensor counts map to channels.
33#[derive(Debug, Clone, PartialEq)]
34pub enum PlaneLayout {
35    /// One `[h, w]` plane; the CFA tile (shape `[tile_h, tile_w]`, values = channel indices) repeats across the sensor. Bayer RGGB is `[[0,1],[1,2]]`.
36    Mosaic { cfa: Tensor<u8> },
37    /// K full-resolution planes, shape `[k, h, w]`. Multispectral composites and already-demosaiced sources.
38    Planar,
39}
40
41/// Identity ingredients for the ihi provenance scheme. All optional in v0; `handle == ""` means omitted (the reserved empty value — an absent wire field reads back as "").
42#[derive(Debug, Clone, Default, PartialEq)]
43pub struct Provenance {
44    pub handle: String,
45    pub calibration_hash: Option<[u8; 32]>,
46    pub camera_ihi: Option<[u8; 32]>,
47    /// The composed ihi identity, computed by callers that link ihi (vsf itself does not).
48    pub identity: Option<[u8; 32]>,
49}
50
51/// VERICHROME IDT taxonomy — how a colour transform relates the captured light to its output. `Absolute`: straight inversion, cast preserved (a DNG-matrix render). `Relative`: DSR spectral solve to a reference (a chameleon magic-9). `Creative`: deliberate shift (white balance, contrast, colour skew). `Technical`: hue-free mechanics (a scalar exposure). Stable four-value vocabulary — unknown values fail loud on read, per the "we control all clients" rule.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum IdtClass {
54    Absolute,
55    Relative,
56    Creative,
57    Technical,
58}
59
60impl IdtClass {
61    pub fn as_str(self) -> &'static str {
62        match self {
63            IdtClass::Absolute => "absolute",
64            IdtClass::Relative => "relative",
65            IdtClass::Creative => "creative",
66            IdtClass::Technical => "technical",
67        }
68    }
69    fn parse(s: &str) -> Result<Self, SpectralImageError> {
70        match s {
71            "absolute" => Ok(IdtClass::Absolute),
72            "relative" => Ok(IdtClass::Relative),
73            "creative" => Ok(IdtClass::Creative),
74            "technical" => Ok(IdtClass::Technical),
75            other => Err(SpectralImageError::BadField(format!("unknown IDT class '{}'", other))),
76        }
77    }
78}
79
80/// Trust grade of a characterization matrix. `Unit`: measured on THIS camera (a magic-9 target scan). `Model`: factory per-model (a DNG ColorMatrix). `Assumed`: implied by the format convention alone (an sRGB JPEG). This is how "assumed profile, best guess" is first-class rather than a lie.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ProfileGrade {
83    Unit,
84    Model,
85    Assumed,
86}
87
88impl ProfileGrade {
89    pub fn as_str(self) -> &'static str {
90        match self {
91            ProfileGrade::Unit => "unit",
92            ProfileGrade::Model => "model",
93            ProfileGrade::Assumed => "assumed",
94        }
95    }
96    fn parse(s: &str) -> Result<Self, SpectralImageError> {
97        match s {
98            "unit" => Ok(ProfileGrade::Unit),
99            "model" => Ok(ProfileGrade::Model),
100            "assumed" => Ok(ProfileGrade::Assumed),
101            other => Err(SpectralImageError::BadField(format!("unknown profile grade '{}'", other))),
102        }
103    }
104}
105
106/// The transfer the *samples* carry before this entry's matrix applies. All camera-raw paths are `Linear`; the rest exist so assumed-observer ingest (sRGB/Adobe JPEG) can record the encoding its code values arrived in, to be linearized before the matrix.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum Transfer {
109    Linear,
110    Srgb,
111    Gamma2,
112    Gamma22,
113}
114
115impl Transfer {
116    pub fn as_str(self) -> &'static str {
117        match self {
118            Transfer::Linear => "linear",
119            Transfer::Srgb => "srgb",
120            Transfer::Gamma2 => "gamma2",
121            Transfer::Gamma22 => "gamma22",
122        }
123    }
124    fn parse(s: &str) -> Result<Self, SpectralImageError> {
125        match s {
126            "linear" => Ok(Transfer::Linear),
127            "srgb" => Ok(Transfer::Srgb),
128            "gamma2" => Ok(Transfer::Gamma2),
129            "gamma22" => Ok(Transfer::Gamma22),
130            other => Err(SpectralImageError::BadField(format!("unknown transfer '{}'", other))),
131        }
132    }
133}
134
135/// One characterization: a camera→target matrix and the full account of where it came from and how far to trust it. `matrix` is row-major camera→VSF-RGB (linear in, linear out), UNSCALED — no display or illuminant normalization baked in, so the same entry serves any output space by concatenation.
136#[derive(Debug, Clone, PartialEq)]
137pub struct ProfileEntry {
138    pub matrix: [f32; 9],
139    /// Free-form derivation token (`magic9`, `dng_colormatrix1`, `assumed_srgb`, …) — open vocabulary; an unknown token still names a working matrix, it just reads opaque.
140    pub source: String,
141    pub class: IdtClass,
142    pub grade: ProfileGrade,
143    /// EXIF LightSource code of the characterization illuminant; 0 = unknown. For deriving a display exposure scalar only — never chromatic adaptation.
144    pub illuminant: u16,
145    pub transfer: Transfer,
146}
147
148/// magic-9 calibration provenance carried alongside a `unit`-grade entry: which target produced it.
149#[derive(Debug, Clone, PartialEq)]
150pub struct CalProvenance {
151    pub target_type: u32,
152    pub target_serial: u64,
153    pub timestamp: String,
154}
155
156/// Tiered characterization: the best available mappings from sensor counts to VSF RGB, best entry first, plus the raw questions they answer (verbatim DNG tags, solve patches, calibration provenance) so any reader can audit or re-derive. Elected under `spectral_response` curves, which always win when present. `target` is the destination space every matrix here maps to — v0 legal value `"vsf_rgb"`; a reader seeing any other target treats the file as uncharacterized.
157#[derive(Debug, Clone, PartialEq)]
158pub struct ColourProfile {
159    pub target: String,
160    pub entries: Vec<ProfileEntry>,
161    /// Verbatim DNG ColorMatrix1/2 (XYZ→camera) + CalibrationIlluminant codes, untouched — the question the derived entries answer.
162    pub dng_colormatrix: [Option<([f32; 9], u16)>; 2],
163    /// magic-9 solve inputs: (camera-space patch means, reference patch values in VSF RGB), each flat `[p*3]`.
164    pub patches: Option<(Vec<f32>, Vec<f32>)>,
165    pub cal: Option<CalProvenance>,
166}
167
168/// One transformation in the translateration log: a named op, its IDT class, and its parameters. Order in [`ViewTransform::ops`] is application order.
169#[derive(Debug, Clone, PartialEq)]
170pub struct ViewOp {
171    /// Op name — open vocabulary; v0 defines `exposure`. Reserved: `curve`, `contrast`, `skew_matrix`, `white_balance`, `dr_curve`. A reader that meets an unknown op MUST surface it, never silently drop it.
172    pub name: String,
173    pub class: IdtClass,
174    pub params: Vec<f32>,
175}
176
177/// The translateration log: ordered view transformations applied after characterization, in `space`. The image DATA is never touched — this is interpretation metadata that downstream tools (oriel) replay. `space` v0 value `"vsf_rgb_linear"` (after the elected matrix, before display encode).
178#[derive(Debug, Clone, PartialEq)]
179pub struct ViewTransform {
180    pub space: String,
181    pub ops: Vec<ViewOp>,
182}
183
184/// A spectral-first raw image: K channels of sensor counts plus what those counts *mean*.
185#[derive(Debug, Clone, PartialEq)]
186pub struct SpectralImage {
187    pub width: usize,
188    pub height: usize,
189    pub channels: Vec<SpectralChannel>,
190    pub layout: PlaneLayout,
191    /// Sensor counts, bitpacked at native depth. Mosaic: shape `[height, width]`. Planar: `[channels, height, width]`.
192    pub samples: BitPackedTensor,
193    /// Per-channel black level in raw counts.
194    pub black: Vec<f32>,
195    /// Per-channel white (saturation) level in raw counts.
196    pub white: Vec<f32>,
197    pub make: String,
198    pub model: String,
199    pub provenance: Provenance,
200    /// Tiered camera→VSF-RGB characterization; `None` = uncharacterized (render raw-camera). See [`ColourProfile`].
201    pub profile: Option<ColourProfile>,
202    /// The translateration log; `None` = no view ops recorded. See [`ViewTransform`].
203    pub view: Option<ViewTransform>,
204}
205
206#[derive(Debug)]
207pub enum SpectralImageError {
208    Parse(String),
209    MissingSection(&'static str),
210    MissingField(&'static str),
211    BadField(String),
212}
213
214impl core::fmt::Display for SpectralImageError {
215    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
216        match self {
217            SpectralImageError::Parse(s) => write!(f, "VSF parse: {}", s),
218            SpectralImageError::MissingSection(s) => write!(f, "no '{}' section in file", s),
219            SpectralImageError::MissingField(s) => write!(f, "missing field '{}'", s),
220            SpectralImageError::BadField(s) => write!(f, "bad field: {}", s),
221        }
222    }
223}
224
225#[cfg(feature = "std")]
226impl std::error::Error for SpectralImageError {}
227
228impl SpectralImage {
229    /// Total channel count K.
230    pub fn channel_count(&self) -> usize {
231        self.channels.len()
232    }
233
234    /// Native bit depth of the sensor counts.
235    pub fn bit_depth(&self) -> u8 {
236        self.samples.bit_depth
237    }
238}
239
240/// Serialize to a complete VSF file (header + sections + BLAKE3 integrity, all handled by [`VsfBuilder`]).
241pub fn write(img: &SpectralImage) -> Result<Vec<u8>, String> {
242    let k = img.channels.len();
243    if img.black.len() != k || img.white.len() != k {
244        return Err(format!("black/white length {}/{} != channel count {}", img.black.len(), img.white.len(), k));
245    }
246
247    let mut fields: Vec<(String, VsfType)> = vec![
248        ("width".to_string(), VsfType::u(img.width, false)),
249        ("height".to_string(), VsfType::u(img.height, false)),
250        ("channel_count".to_string(), VsfType::u(k, false)),
251        ("black".to_string(), VsfType::t_f5(Tensor::new(vec![k], img.black.clone()))),
252        ("white".to_string(), VsfType::t_f5(Tensor::new(vec![k], img.white.clone()))),
253        ("channel_names".to_string(), VsfType::a(img.channels.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join("\n"))),
254    ];
255    match &img.layout {
256        PlaneLayout::Mosaic { cfa } => {
257            fields.push(("layout".to_string(), VsfType::a("mosaic".to_string())));
258            fields.push(("cfa".to_string(), VsfType::t_u3(cfa.clone())));
259        }
260        PlaneLayout::Planar => fields.push(("layout".to_string(), VsfType::a("planar".to_string()))),
261    }
262    if !img.make.is_empty() {
263        fields.push(("make".to_string(), VsfType::a(img.make.clone())));
264    }
265    if !img.model.is_empty() {
266        fields.push(("model".to_string(), VsfType::a(img.model.clone())));
267    }
268    fields.push(("samples".to_string(), VsfType::p(img.samples.clone())));
269
270    let mut builder = VsfBuilder::new().add_section("spectral_image", fields);
271
272    // Spectral response — only when at least one channel is characterized. Uncharacterized channels in a mixed set carry count 0 and contribute nothing to curve_values.
273    if img.channels.iter().any(|c| c.curve.is_some()) {
274        let mut starts = Vec::with_capacity(k);
275        let mut steps = Vec::with_capacity(k);
276        let mut counts: Vec<u32> = Vec::with_capacity(k);
277        let mut values: Vec<f32> = Vec::new();
278        for c in &img.channels {
279            match &c.curve {
280                Some(curve) => {
281                    starts.push(curve.start_nm);
282                    steps.push(curve.step_nm);
283                    counts.push(curve.values.len() as u32);
284                    values.extend_from_slice(&curve.values);
285                }
286                None => {
287                    starts.push(0.0);
288                    steps.push(0.0);
289                    counts.push(0);
290                }
291            }
292        }
293        let total = values.len();
294        builder = builder.add_section(
295            "spectral_response",
296            vec![
297                ("curve_start".to_string(), VsfType::t_f5(Tensor::new(vec![k], starts))),
298                ("curve_step".to_string(), VsfType::t_f5(Tensor::new(vec![k], steps))),
299                ("curve_counts".to_string(), VsfType::t_u5(Tensor::new(vec![k], counts))),
300                ("curve_values".to_string(), VsfType::t_f5(Tensor::new(vec![total], values))),
301            ],
302        );
303    }
304
305    // Provenance — every field individually optional; the section itself is omitted when it would be empty. An empty handle is *not* written: "" is the reserved omission value and is exactly what read() yields for the absent field.
306    let p = &img.provenance;
307    let mut prov: Vec<(String, VsfType)> = Vec::new();
308    if !p.handle.is_empty() {
309        // Handles are Unicode and canonically Huffman-encoded (x) — that's what ihi hashes. Without the text feature compiled in, fall back to ASCII (a) and reject what it can't hold rather than silently mangling.
310        #[cfg(any(feature = "text", feature = "text-encode"))]
311        prov.push(("handle".to_string(), VsfType::x(p.handle.clone())));
312        #[cfg(not(any(feature = "text", feature = "text-encode")))]
313        {
314            if !p.handle.is_ascii() {
315                return Err("non-ASCII handle requires the 'text' or 'text-encode' feature".to_string());
316            }
317            prov.push(("handle".to_string(), VsfType::a(p.handle.clone())));
318        }
319    }
320    if let Some(h) = &p.calibration_hash {
321        prov.push(("calibration_hash".to_string(), VsfType::hp(h.to_vec())));
322    }
323    if let Some(h) = &p.camera_ihi {
324        prov.push(("camera_ihi".to_string(), VsfType::hs(h.to_vec())));
325    }
326    if let Some(h) = &p.identity {
327        prov.push(("identity".to_string(), VsfType::hs(h.to_vec())));
328    }
329    if !prov.is_empty() {
330        builder = builder.add_section("provenance", prov);
331    }
332
333    // Colour profile — tiered characterization. Omitted entirely when uncharacterized. Entries become parallel arrays (best first); the raw-question fields ride alongside, each individually optional.
334    if let Some(profile) = &img.profile {
335        let n = profile.entries.len();
336        let mut matrices: Vec<f32> = Vec::with_capacity(n * 9);
337        let mut sources: Vec<&str> = Vec::with_capacity(n);
338        let mut classes: Vec<&str> = Vec::with_capacity(n);
339        let mut grades: Vec<&str> = Vec::with_capacity(n);
340        let mut illuminants: Vec<u16> = Vec::with_capacity(n);
341        let mut transfers: Vec<&str> = Vec::with_capacity(n);
342        for e in &profile.entries {
343            matrices.extend_from_slice(&e.matrix);
344            sources.push(&e.source);
345            classes.push(e.class.as_str());
346            grades.push(e.grade.as_str());
347            illuminants.push(e.illuminant);
348            transfers.push(e.transfer.as_str());
349        }
350        let mut fields: Vec<(String, VsfType)> = vec![
351            ("target".to_string(), VsfType::a(profile.target.clone())),
352            ("count".to_string(), VsfType::u(n, false)),
353            ("matrices".to_string(), VsfType::t_f5(Tensor::new(vec![n, 3, 3], matrices))),
354            ("sources".to_string(), VsfType::a(sources.join("\n"))),
355            ("classes".to_string(), VsfType::a(classes.join("\n"))),
356            ("grades".to_string(), VsfType::a(grades.join("\n"))),
357            ("illuminants".to_string(), VsfType::t_u4(Tensor::new(vec![n], illuminants))),
358            ("transfers".to_string(), VsfType::a(transfers.join("\n"))),
359        ];
360        for (i, name) in ["dng_colormatrix1", "dng_colormatrix2"].iter().enumerate() {
361            if let Some((m, code)) = &profile.dng_colormatrix[i] {
362                fields.push((name.to_string(), VsfType::t_f5(Tensor::new(vec![3, 3], m.to_vec()))));
363                let illum_name = if i == 0 { "dng_illuminant1" } else { "dng_illuminant2" };
364                fields.push((illum_name.to_string(), VsfType::u(*code as usize, false)));
365            }
366        }
367        if let Some((cam, reference)) = &profile.patches {
368            let p = cam.len() / 3;
369            fields.push(("patches_camera".to_string(), VsfType::t_f5(Tensor::new(vec![p, 3], cam.clone()))));
370            fields.push(("patches_reference".to_string(), VsfType::t_f5(Tensor::new(vec![p, 3], reference.clone()))));
371        }
372        if let Some(cal) = &profile.cal {
373            fields.push(("cal_target_type".to_string(), VsfType::u(cal.target_type as usize, false)));
374            fields.push(("cal_target_serial".to_string(), VsfType::u(cal.target_serial as usize, false)));
375            fields.push(("cal_timestamp".to_string(), VsfType::a(cal.timestamp.clone())));
376        }
377        builder = builder.add_section("colour_profile", fields);
378    }
379
380    // View transform — the translateration log. Omitted when empty. Ops become parallel arrays; params concatenate, sliced back by param_counts.
381    if let Some(view) = &img.view {
382        let m = view.ops.len();
383        let mut names: Vec<&str> = Vec::with_capacity(m);
384        let mut classes: Vec<&str> = Vec::with_capacity(m);
385        let mut param_counts: Vec<u32> = Vec::with_capacity(m);
386        let mut params: Vec<f32> = Vec::new();
387        for op in &view.ops {
388            names.push(&op.name);
389            classes.push(op.class.as_str());
390            param_counts.push(op.params.len() as u32);
391            params.extend_from_slice(&op.params);
392        }
393        let total = params.len();
394        builder = builder.add_section(
395            "view_transform",
396            vec![
397                ("space".to_string(), VsfType::a(view.space.clone())),
398                ("ops".to_string(), VsfType::a(names.join("\n"))),
399                ("classes".to_string(), VsfType::a(classes.join("\n"))),
400                ("param_counts".to_string(), VsfType::t_u5(Tensor::new(vec![m], param_counts))),
401                ("params".to_string(), VsfType::t_f5(Tensor::new(vec![total], params))),
402            ],
403        );
404    }
405
406    builder.build()
407}
408
409/// Parse a VSF-Image file back into a [`SpectralImage`].
410pub fn read(data: &[u8]) -> Result<SpectralImage, SpectralImageError> {
411    let (header, _) = VsfHeader::decode(data).map_err(SpectralImageError::Parse)?;
412
413    let main = section_fields(data, &header, "spectral_image")?.ok_or(SpectralImageError::MissingSection("spectral_image"))?;
414
415    let width = take_usize(&main, "width")?;
416    let height = take_usize(&main, "height")?;
417    let k = take_usize(&main, "channel_count")?;
418    let black = take_f32_vec(&main, "black")?;
419    let white = take_f32_vec(&main, "white")?;
420    let names_joined = take_string(&main, "channel_names")?;
421    let layout_str = take_string(&main, "layout")?;
422    let make = take_string_opt(&main, "make").unwrap_or_default();
423    let model = take_string_opt(&main, "model").unwrap_or_default();
424
425    let samples = match find(&main, "samples") {
426        Some(VsfType::p(t)) => t.clone(),
427        Some(_) => return Err(SpectralImageError::BadField("samples is not a BitPackedTensor".into())),
428        None => return Err(SpectralImageError::MissingField("samples")),
429    };
430
431    let layout = match layout_str.as_str() {
432        "mosaic" => match find(&main, "cfa") {
433            Some(VsfType::t_u3(t)) => PlaneLayout::Mosaic { cfa: t.clone() },
434            Some(_) => return Err(SpectralImageError::BadField("cfa is not a u8 tensor".into())),
435            None => return Err(SpectralImageError::MissingField("cfa")),
436        },
437        "planar" => PlaneLayout::Planar,
438        other => return Err(SpectralImageError::BadField(format!("unknown layout '{}'", other))),
439    };
440
441    // Channel names: '\n'-joined. write() emits exactly k names; a mismatch means the file is corrupt or hand-forged — fail loud, don't invent labels that would mask it.
442    let names: Vec<String> = names_joined.split('\n').map(str::to_string).collect();
443    if names.len() != k {
444        return Err(SpectralImageError::BadField(format!("channel_names carries {} names for {} channels", names.len(), k)));
445    }
446
447    // Curves, if the file carries a spectral_response section.
448    let mut curves: Vec<Option<SpectralCurve>> = vec![None; k];
449    if let Some(resp) = section_fields(data, &header, "spectral_response")? {
450        let starts = take_f32_vec(&resp, "curve_start")?;
451        let steps = take_f32_vec(&resp, "curve_step")?;
452        let counts = take_u32_vec(&resp, "curve_counts")?;
453        let values = take_f32_vec(&resp, "curve_values")?;
454        if starts.len() != k || steps.len() != k || counts.len() != k {
455            return Err(SpectralImageError::BadField(format!("spectral_response arrays sized {}/{}/{} for {} channels", starts.len(), steps.len(), counts.len(), k)));
456        }
457        let mut cursor = 0usize;
458        for i in 0..k {
459            let n = counts[i] as usize;
460            if n == 0 {
461                continue;
462            }
463            if cursor + n > values.len() {
464                return Err(SpectralImageError::BadField("curve_values shorter than curve_counts total".into()));
465            }
466            curves[i] = Some(SpectralCurve { start_nm: starts[i], step_nm: steps[i], values: values[cursor..cursor + n].to_vec() });
467            cursor += n;
468        }
469    }
470
471    let channels = names.into_iter().zip(curves).map(|(name, curve)| SpectralChannel { name, curve }).collect();
472
473    // Provenance — absent section or absent fields read back as the defaults ("" handle = omitted, None hashes).
474    let mut provenance = Provenance::default();
475    if let Some(prov) = section_fields(data, &header, "provenance")? {
476        if let Some(h) = take_string_opt(&prov, "handle") {
477            provenance.handle = h;
478        }
479        provenance.calibration_hash = take_hash32(&prov, "calibration_hash");
480        provenance.camera_ihi = take_hash32(&prov, "camera_ihi");
481        provenance.identity = take_hash32(&prov, "identity");
482    }
483
484    if black.len() != k || white.len() != k {
485        return Err(SpectralImageError::BadField(format!("black/white length {}/{} != channel count {}", black.len(), white.len(), k)));
486    }
487
488    let profile = read_colour_profile(data, &header)?;
489    let view = read_view_transform(data, &header)?;
490
491    Ok(SpectralImage { width, height, channels, layout, samples, black, white, make, model, provenance, profile, view })
492}
493
494/// Split a '\n'-joined field into exactly `n` parts, failing loud on a count mismatch — a corrupt or hand-forged parallel array must not silently desync from its siblings.
495fn split_n(joined: &str, n: usize, what: &str) -> Result<Vec<String>, SpectralImageError> {
496    let parts: Vec<String> = joined.split('\n').map(str::to_string).collect();
497    if parts.len() != n {
498        return Err(SpectralImageError::BadField(format!("{} carries {} entries for {} rows", what, parts.len(), n)));
499    }
500    Ok(parts)
501}
502
503/// Parse the optional `colour_profile` section into a [`ColourProfile`]. Absent section ⇒ `None` (uncharacterized). Parallel arrays are length-checked against `count`; the matrices tensor against `count*9`.
504fn read_colour_profile(data: &[u8], header: &VsfHeader) -> Result<Option<ColourProfile>, SpectralImageError> {
505    let Some(sec) = section_fields(data, header, "colour_profile")? else {
506        return Ok(None);
507    };
508    let target = take_string(&sec, "target")?;
509    let n = take_usize(&sec, "count")?;
510    let matrices = take_f32_vec(&sec, "matrices")?;
511    if matrices.len() != n * 9 {
512        return Err(SpectralImageError::BadField(format!("colour_profile matrices carries {} values for {} entries", matrices.len(), n)));
513    }
514    let sources = split_n(&take_string(&sec, "sources")?, n, "colour_profile sources")?;
515    let classes = split_n(&take_string(&sec, "classes")?, n, "colour_profile classes")?;
516    let grades = split_n(&take_string(&sec, "grades")?, n, "colour_profile grades")?;
517    let transfers = split_n(&take_string(&sec, "transfers")?, n, "colour_profile transfers")?;
518    let illuminants = take_u32_vec(&sec, "illuminants")?;
519    if illuminants.len() != n {
520        return Err(SpectralImageError::BadField(format!("colour_profile illuminants carries {} values for {} entries", illuminants.len(), n)));
521    }
522    let mut entries = Vec::with_capacity(n);
523    for i in 0..n {
524        let mut matrix = [0f32; 9];
525        matrix.copy_from_slice(&matrices[i * 9..i * 9 + 9]);
526        entries.push(ProfileEntry {
527            matrix,
528            source: sources[i].clone(),
529            class: IdtClass::parse(&classes[i])?,
530            grade: ProfileGrade::parse(&grades[i])?,
531            illuminant: illuminants[i] as u16,
532            transfer: Transfer::parse(&transfers[i])?,
533        });
534    }
535
536    let mut dng_colormatrix: [Option<([f32; 9], u16)>; 2] = [None, None];
537    for (i, (mname, iname)) in [("dng_colormatrix1", "dng_illuminant1"), ("dng_colormatrix2", "dng_illuminant2")].iter().enumerate() {
538        if let Some(f) = sec.iter().find(|f| f.name == *mname) {
539            let flat = match f.values.first() {
540                Some(VsfType::t_f5(t)) => t.data.clone(),
541                Some(VsfType::v_f5(v)) => v.data.clone(),
542                _ => return Err(SpectralImageError::BadField(format!("{} is not an f32 tensor", mname))),
543            };
544            if flat.len() != 9 {
545                return Err(SpectralImageError::BadField(format!("{} carries {} values, need 9", mname, flat.len())));
546            }
547            let mut m = [0f32; 9];
548            m.copy_from_slice(&flat);
549            let code = take_usize(&sec, iname).unwrap_or(0) as u16;
550            dng_colormatrix[i] = Some((m, code));
551        }
552    }
553
554    let patches = match (sec.iter().any(|f| f.name == "patches_camera"), sec.iter().any(|f| f.name == "patches_reference")) {
555        (true, true) => Some((take_f32_vec(&sec, "patches_camera")?, take_f32_vec(&sec, "patches_reference")?)),
556        (false, false) => None,
557        _ => return Err(SpectralImageError::BadField("colour_profile has one of patches_camera/patches_reference without the other".into())),
558    };
559
560    let cal = if sec.iter().any(|f| f.name == "cal_target_type") {
561        Some(CalProvenance {
562            target_type: take_usize(&sec, "cal_target_type")? as u32,
563            target_serial: take_usize(&sec, "cal_target_serial")? as u64,
564            timestamp: take_string(&sec, "cal_timestamp")?,
565        })
566    } else {
567        None
568    };
569
570    Ok(Some(ColourProfile { target, entries, dng_colormatrix, patches, cal }))
571}
572
573/// Parse the optional `view_transform` section into a [`ViewTransform`]. Absent section ⇒ `None`. Params are sliced back by param_counts; a shortfall fails loud.
574fn read_view_transform(data: &[u8], header: &VsfHeader) -> Result<Option<ViewTransform>, SpectralImageError> {
575    let Some(sec) = section_fields(data, header, "view_transform")? else {
576        return Ok(None);
577    };
578    let space = take_string(&sec, "space")?;
579    let param_counts = take_u32_vec(&sec, "param_counts")?;
580    let m = param_counts.len();
581    let names = split_n(&take_string(&sec, "ops")?, m, "view_transform ops")?;
582    let classes = split_n(&take_string(&sec, "classes")?, m, "view_transform classes")?;
583    let params = take_f32_vec(&sec, "params")?;
584    let mut ops = Vec::with_capacity(m);
585    let mut cursor = 0usize;
586    for i in 0..m {
587        let count = param_counts[i] as usize;
588        if cursor + count > params.len() {
589            return Err(SpectralImageError::BadField("view_transform params shorter than param_counts total".into()));
590        }
591        ops.push(ViewOp {
592            name: names[i].clone(),
593            class: IdtClass::parse(&classes[i])?,
594            params: params[cursor..cursor + count].to_vec(),
595        });
596        cursor += count;
597    }
598    Ok(Some(ViewTransform { space, ops }))
599}
600
601/// Walk a named section's body and return its fields, or Ok(None) when the section isn't in the file. Mirrors the section-body walk in [`crate::image::decode`].
602fn section_fields(data: &[u8], header: &VsfHeader, name: &str) -> Result<Option<Vec<VsfField>>, SpectralImageError> {
603    let Some(field) = header.fields.iter().find(|f| f.name == name) else {
604        return Ok(None);
605    };
606    let mut p = field.offset_bytes;
607    if p >= data.len() {
608        return Err(SpectralImageError::Parse(format!("'{}' section offset {} beyond file length {}", name, p, data.len())));
609    }
610    if data[p] == b'>' {
611        p += 1;
612    }
613    if p >= data.len() || data[p] != b'[' {
614        return Err(SpectralImageError::Parse(format!("expected '[' at '{}' section start, got byte {:02x}", name, data.get(p).copied().unwrap_or(0))));
615    }
616    p += 1;
617    if p < data.len() && data[p] != b'(' {
618        parse(data, &mut p).map_err(|e| SpectralImageError::Parse(format!("section name: {:?}", e)))?;
619        parse(data, &mut p).map_err(|e| SpectralImageError::Parse(format!("section n: {:?}", e)))?;
620        parse(data, &mut p).map_err(|e| SpectralImageError::Parse(format!("section b: {:?}", e)))?;
621    }
622    let mut fields = Vec::with_capacity(field.child_count);
623    for _ in 0..field.child_count {
624        fields.push(VsfField::parse(data, &mut p).map_err(|e| SpectralImageError::Parse(format!("field parse: {}", e)))?);
625    }
626    Ok(Some(fields))
627}
628
629fn find<'a>(fields: &'a [VsfField], name: &str) -> Option<&'a VsfType> {
630    fields.iter().find(|f| f.name == name).and_then(|f| f.values.first())
631}
632
633fn take_usize(fields: &[VsfField], name: &'static str) -> Result<usize, SpectralImageError> {
634    match find(fields, name) {
635        Some(VsfType::u(v, _)) => Ok(*v),
636        Some(VsfType::u3(v)) => Ok(*v as usize),
637        Some(VsfType::u4(v)) => Ok(*v as usize),
638        Some(VsfType::u5(v)) => Ok(*v as usize),
639        Some(VsfType::u6(v)) => Ok(*v as usize),
640        Some(VsfType::n(v)) => Ok(*v),
641        Some(_) => Err(SpectralImageError::BadField(format!("'{}' is not an unsigned integer", name))),
642        None => Err(SpectralImageError::MissingField(name)),
643    }
644}
645
646fn take_string(fields: &[VsfField], name: &'static str) -> Result<String, SpectralImageError> {
647    take_string_opt(fields, name).ok_or(SpectralImageError::MissingField(name))
648}
649
650fn take_string_opt(fields: &[VsfField], name: &str) -> Option<String> {
651    match find(fields, name) {
652        Some(VsfType::a(s)) | Some(VsfType::x(s)) => Some(s.clone()),
653        _ => None,
654    }
655}
656
657fn take_f32_vec(fields: &[VsfField], name: &'static str) -> Result<Vec<f32>, SpectralImageError> {
658    // 1D tensors take the compact `tn` wire form and parse back as Vector variants, so accept both shapes of the same data.
659    match find(fields, name) {
660        Some(VsfType::t_f5(t)) => Ok(t.data.clone()),
661        Some(VsfType::v_f5(v)) => Ok(v.data.clone()),
662        Some(_) => Err(SpectralImageError::BadField(format!("'{}' is not an f32 tensor", name))),
663        None => Err(SpectralImageError::MissingField(name)),
664    }
665}
666
667fn take_u32_vec(fields: &[VsfField], name: &'static str) -> Result<Vec<u32>, SpectralImageError> {
668    // 1D unsigned tensors come back as Vector variants (compact `tn` wire form); accept tensor and vector forms at any width and widen to u32.
669    match find(fields, name) {
670        Some(VsfType::t_u3(t)) => Ok(t.data.iter().map(|&v| v as u32).collect()),
671        Some(VsfType::t_u4(t)) => Ok(t.data.iter().map(|&v| v as u32).collect()),
672        Some(VsfType::t_u5(t)) => Ok(t.data.clone()),
673        Some(VsfType::v_u3(v)) => Ok(v.data.iter().map(|&v| v as u32).collect()),
674        Some(VsfType::v_u4(v)) => Ok(v.data.iter().map(|&v| v as u32).collect()),
675        Some(VsfType::v_u5(v)) => Ok(v.data.clone()),
676        Some(_) => Err(SpectralImageError::BadField(format!("'{}' is not an unsigned tensor", name))),
677        None => Err(SpectralImageError::MissingField(name)),
678    }
679}
680
681fn take_hash32(fields: &[VsfField], name: &str) -> Option<[u8; 32]> {
682    match find(fields, name) {
683        Some(VsfType::hp(v)) | Some(VsfType::hs(v)) | Some(VsfType::hb(v)) => v.as_slice().try_into().ok(),
684        _ => None,
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    fn bayer_test_image() -> SpectralImage {
693        // 4×4 RGGB mosaic, 12-bit counts.
694        let counts: Vec<u16> = (0..16).map(|i| (i * 200) as u16).collect();
695        SpectralImage {
696            width: 4,
697            height: 4,
698            channels: vec![
699                SpectralChannel { name: "R".into(), curve: None },
700                SpectralChannel { name: "G".into(), curve: None },
701                SpectralChannel { name: "B".into(), curve: None },
702            ],
703            layout: PlaneLayout::Mosaic { cfa: Tensor::new(vec![2, 2], vec![0, 1, 1, 2]) },
704            samples: BitPackedTensor::pack(12, vec![4, 4], &counts),
705            black: vec![512.0; 3],
706            white: vec![4095.0; 3],
707            make: "VERICHROME".into(),
708            model: "TestCam".into(),
709            provenance: Provenance::default(),
710            profile: None,
711            view: None,
712        }
713    }
714
715    #[test]
716    fn mosaic_round_trip() {
717        let img = bayer_test_image();
718        let bytes = write(&img).expect("write");
719        let back = read(&bytes).expect("read");
720        assert_eq!(back, img);
721        assert_eq!(back.samples.unpack_u16(), img.samples.unpack_u16());
722        assert_eq!(back.bit_depth(), 12);
723    }
724
725    #[test]
726    fn planar_with_curves_round_trip() {
727        // 2 channels × 2×2, one characterized on a 5nm grid and one uncharacterized — the mixed case.
728        let img = SpectralImage {
729            width: 2,
730            height: 2,
731            channels: vec![
732                SpectralChannel { name: "LED_450".into(), curve: Some(SpectralCurve { start_nm: 400.0, step_nm: 5.0, values: vec![0.1, 0.9, 0.4] }) },
733                SpectralChannel { name: "LED_650".into(), curve: None },
734            ],
735            layout: PlaneLayout::Planar,
736            samples: BitPackedTensor::pack(16, vec![2, 2, 2], &[1u16, 2, 3, 4, 5, 6, 7, 8]),
737            black: vec![0.0, 0.0],
738            white: vec![65535.0, 65535.0],
739            make: String::new(),
740            model: String::new(),
741            provenance: Provenance { handle: "nick".into(), calibration_hash: Some([7u8; 32]), camera_ihi: None, identity: Some([9u8; 32]) },
742            profile: None,
743            view: None,
744        };
745        let bytes = write(&img).expect("write");
746        let back = read(&bytes).expect("read");
747        assert_eq!(back, img);
748    }
749
750    #[test]
751    fn colour_profile_and_view_round_trip() {
752        // Two-entry profile (magic-9 unit + DNG model), verbatim DNG tags, solve patches, cal provenance, plus a one-op view log.
753        let mut img = bayer_test_image();
754        img.profile = Some(ColourProfile {
755            target: "vsf_rgb".into(),
756            entries: vec![
757                ProfileEntry {
758                    matrix: [1.1, 0.0, 0.0, 0.0, 0.9, 0.0, 0.0, 0.0, 1.2],
759                    source: "magic9".into(),
760                    class: IdtClass::Relative,
761                    grade: ProfileGrade::Unit,
762                    illuminant: 21,
763                    transfer: Transfer::Linear,
764                },
765                ProfileEntry {
766                    matrix: [0.8, 0.1, 0.05, 0.02, 0.95, 0.03, 0.01, 0.04, 1.1],
767                    source: "dng_colormatrix1".into(),
768                    class: IdtClass::Absolute,
769                    grade: ProfileGrade::Model,
770                    illuminant: 23,
771                    transfer: Transfer::Linear,
772                },
773            ],
774            dng_colormatrix: [Some(([1.0, 0.1, 0.0, 0.0, 1.0, 0.1, 0.0, 0.0, 1.0], 23)), None],
775            patches: Some((vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6], vec![0.11, 0.21, 0.31, 0.41, 0.51, 0.61])),
776            cal: Some(CalProvenance { target_type: 9, target_serial: 12345, timestamp: "eagle".into() }),
777        });
778        img.view = Some(ViewTransform {
779            space: "vsf_rgb_linear".into(),
780            ops: vec![ViewOp { name: "exposure".into(), class: IdtClass::Technical, params: vec![1.5] }],
781        });
782        let bytes = write(&img).expect("write");
783        let back = read(&bytes).expect("read");
784        assert_eq!(back, img);
785    }
786
787    #[test]
788    fn absent_profile_and_view_read_none() {
789        let img = bayer_test_image();
790        let back = read(&write(&img).expect("write")).expect("read");
791        assert!(back.profile.is_none());
792        assert!(back.view.is_none());
793    }
794
795    #[test]
796    fn empty_handle_is_omitted_and_reads_back_empty() {
797        let img = bayer_test_image();
798        let bytes = write(&img).expect("write");
799        assert!(!String::from_utf8_lossy(&bytes).contains("handle"));
800        assert_eq!(read(&bytes).expect("read").provenance.handle, "");
801    }
802}