spectro_rs/
spectrum.rs

1use crate::WAVELENGTHS;
2use crate::colorimetry::{X_BAR_2, X_BAR_10, XYZ, Y_BAR_2, Y_BAR_10, Z_BAR_2, Z_BAR_10, weighting};
3use crate::{Illuminant, Observer};
4
5/// Measurement mode determines the calculation method for XYZ conversion.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
7pub enum MeasurementMode {
8    /// Reflective measurement (objects like paper, color patches)
9    /// Uses ASTM E308 weighting factors which include D65 SPD
10    #[default]
11    Reflective,
12    /// Emissive measurement (light sources like displays, lamps)
13    /// Uses direct CMF integration
14    Emissive,
15    /// Ambient light measurement (same as Emissive but typically with cosine corrector)
16    Ambient,
17}
18
19/// A consolidated result object containing all standard colorimetric values.
20/// This enforces Single Source of Truth by ensuring that derived values (XYZ, Lab, RGB)
21/// are always calculated consistently alongside the spectral data.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct MeasurementResult {
24    pub spectrum: SpectralData,
25    /// CIE 1931 XYZ (D50 adapted)
26    pub xyz: XYZ,
27    /// CIE L*a*b* (D50 illuminant)
28    pub lab: crate::colorimetry::Lab,
29    /// sRGB (0-1 range, D65)
30    pub rgb: (f32, f32, f32),
31    /// Correlated Color Temperature (K)
32    pub cct: f32,
33    /// Color Rendering Index (Ra) - Placeholder for now
34    pub cri: Option<f32>,
35}
36
37impl MeasurementResult {
38    /// Get RGB values in 0-255 range
39    pub fn rgb_u8(&self) -> (u8, u8, u8) {
40        (
41            (self.rgb.0.clamp(0.0, 1.0) * 255.0).round() as u8,
42            (self.rgb.1.clamp(0.0, 1.0) * 255.0).round() as u8,
43            (self.rgb.2.clamp(0.0, 1.0) * 255.0).round() as u8,
44        )
45    }
46
47    /// Calculate the peak wavelength (nm)
48    pub fn peak_wavelength(&self) -> f32 {
49        let (idx, _) = self.spectrum.values.iter().enumerate().fold(
50            (0, 0.0f32),
51            |(max_idx, max_val), (idx, &val)| {
52                if val > max_val {
53                    (idx, val)
54                } else {
55                    (max_idx, max_val)
56                }
57            },
58        );
59        380.0 + idx as f32 * 10.0
60    }
61
62    /// Calculate the centroid wavelength (nm)
63    pub fn centroid_wavelength(&self) -> f32 {
64        let total: f32 = self.spectrum.values.iter().sum();
65        if total < 1e-6 {
66            return 550.0;
67        }
68        let weighted_sum: f32 = self
69            .spectrum
70            .values
71            .iter()
72            .enumerate()
73            .map(|(i, v)| (380.0 + i as f32 * 10.0) * v)
74            .sum();
75        weighted_sum / total
76    }
77}
78
79#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
80pub struct SpectralData {
81    pub wavelengths: Vec<f32>,
82    pub values: Vec<f32>,
83    /// Measurement mode affects XYZ calculation method
84    pub mode: MeasurementMode,
85}
86
87impl SpectralData {
88    pub fn new(mut values: Vec<f32>) -> Self {
89        // Pad with zeros if less than 41 points (common for 380-730nm devices like ColorMunki)
90        while values.len() < 41 {
91            values.push(0.0);
92        }
93        Self {
94            wavelengths: WAVELENGTHS.to_vec(),
95            values,
96            mode: MeasurementMode::default(),
97        }
98    }
99
100    /// Create spectral data with explicit measurement mode
101    pub fn with_mode(mut values: Vec<f32>, mode: MeasurementMode) -> Self {
102        while values.len() < 41 {
103            values.push(0.0);
104        }
105        Self {
106            wavelengths: WAVELENGTHS.to_vec(),
107            values,
108            mode,
109        }
110    }
111
112    /// Set the measurement mode
113    pub fn set_mode(&mut self, mode: MeasurementMode) {
114        self.mode = mode;
115    }
116
117    /// Convert to XYZ using the standard 2-degree observer and D65.
118    /// Default method for backward compatibility.
119    pub fn to_xyz(&self) -> XYZ {
120        self.to_xyz_ext(Illuminant::D65, Observer::CIE1931_2)
121    }
122
123    /// Convert to XYZ using specified illuminant and observer.
124    ///
125    /// For reflective measurements, uses ASTM E308 weighting factors when available.
126    /// Currently supported: D65/2°, D50/2°.
127    pub fn to_xyz_ext(&self, source: Illuminant, obs: Observer) -> XYZ {
128        match self.mode {
129            MeasurementMode::Reflective => {
130                match (source, obs) {
131                    (Illuminant::D65, Observer::CIE1931_2) => self.to_xyz_reflective_weighted(
132                        &weighting::WX_D65_2_10,
133                        &weighting::WY_D65_2_10,
134                        &weighting::WZ_D65_2_10,
135                        weighting::SUM_WY_D65_2_10,
136                    ),
137                    (Illuminant::D50, Observer::CIE1931_2) => self.to_xyz_reflective_weighted(
138                        &weighting::WX_D50_2_10,
139                        &weighting::WY_D50_2_10,
140                        &weighting::WZ_D50_2_10,
141                        weighting::SUM_WY_D50_2_10,
142                    ),
143                    // For other combinations, calculate weighting factors dynamically
144                    _ => {
145                        let spd = source.get_spd();
146                        let (xb, yb, zb) = obs.get_cmfs();
147                        let mut wx = [0.0f32; 41];
148                        let mut wy = [0.0f32; 41];
149                        let mut wz = [0.0f32; 41];
150                        let mut sum_wy = 0.0f32;
151
152                        for i in 0..41 {
153                            wx[i] = spd[i] * xb[i];
154                            wy[i] = spd[i] * yb[i];
155                            wz[i] = spd[i] * zb[i];
156                            sum_wy += wy[i];
157                        }
158
159                        self.to_xyz_reflective_weighted(&wx, &wy, &wz, sum_wy)
160                    }
161                }
162            }
163            MeasurementMode::Emissive | MeasurementMode::Ambient => self.to_xyz_emissive_ext(obs),
164        }
165    }
166
167    /// Convert reflectance to XYZ using provided weighting factors.
168    fn to_xyz_reflective_weighted(
169        &self,
170        wx: &[f32; 41],
171        wy: &[f32; 41],
172        wz: &[f32; 41],
173        sum_wy: f32,
174    ) -> XYZ {
175        let mut x = 0.0f32;
176        let mut y = 0.0f32;
177        let mut z = 0.0f32;
178
179        for i in 0..41 {
180            x += self.values[i] * wx[i];
181            y += self.values[i] * wy[i];
182            z += self.values[i] * wz[i];
183        }
184
185        // Normalize so that Y=100 for a perfect white diffuser
186        let scale = 100.0 / sum_wy;
187
188        XYZ {
189            x: x * scale,
190            y: y * scale,
191            z: z * scale,
192        }
193    }
194
195    /// Resample spectral data to a new wavelength range and step.
196    /// Uses Sprague interpolation for high accuracy, which is recommended
197    /// by the CIE for spectral data resampling.
198    pub fn resample(&self, start: f32, end: f32, step: f32) -> Self {
199        let mut new_values = Vec::new();
200        let mut current_wl = start;
201
202        // Pad values for Sprague (needs 2 before and 3 after)
203        let mut padded_values = Vec::with_capacity(self.values.len() + 5);
204        if !self.values.is_empty() {
205            padded_values.push(self.values[0]);
206            padded_values.push(self.values[0]);
207            padded_values.extend_from_slice(&self.values);
208            padded_values.push(*self.values.last().unwrap());
209            padded_values.push(*self.values.last().unwrap());
210            padded_values.push(*self.values.last().unwrap());
211        } else {
212            return Self {
213                wavelengths: Vec::new(),
214                values: Vec::new(),
215                mode: self.mode,
216            };
217        }
218
219        let orig_start = self.wavelengths[0];
220        let orig_step = if self.wavelengths.len() > 1 {
221            self.wavelengths[1] - self.wavelengths[0]
222        } else {
223            10.0
224        };
225
226        while current_wl <= end + 1e-3 {
227            let t = (current_wl - orig_start) / orig_step;
228            let i = t.floor() as i32;
229            let x = t - i as f32;
230
231            // i is the index of y0 in the original values
232            // In padded_values, y0 is at index i + 2
233            let idx = (i + 2) as usize;
234
235            if idx < 2 || idx + 3 >= padded_values.len() {
236                // Fallback to linear or clamping at edges
237                let val = if current_wl <= orig_start {
238                    self.values[0]
239                } else if current_wl >= orig_start + (self.values.len() - 1) as f32 * orig_step {
240                    *self.values.last().unwrap()
241                } else {
242                    let i_idx = i.max(0) as usize;
243                    let v0 = self.values[i_idx];
244                    let v1 = self.values[(i_idx + 1).min(self.values.len() - 1)];
245                    v0 + x * (v1 - v0)
246                };
247                new_values.push(val);
248            } else {
249                let y = [
250                    padded_values[idx - 2],
251                    padded_values[idx - 1],
252                    padded_values[idx],
253                    padded_values[idx + 1],
254                    padded_values[idx + 2],
255                    padded_values[idx + 3],
256                ];
257                new_values.push(Self::sprague_interpolate(x, &y));
258            }
259
260            current_wl += step;
261        }
262
263        let mut wavelengths = Vec::new();
264        let mut wl = start;
265        while wl <= end + 1e-3 {
266            wavelengths.push(wl);
267            wl += step;
268        }
269
270        Self {
271            wavelengths,
272            values: new_values,
273            mode: self.mode,
274        }
275    }
276
277    /// Sprague interpolation for a point x in [0, 1] between y[2] and y[3].
278    /// y must contain 6 points: y[-2], y[-1], y[0], y[1], y[2], y[3].
279    fn sprague_interpolate(x: f32, y: &[f32; 6]) -> f32 {
280        let x2 = x * x;
281        let x3 = x2 * x;
282        let x4 = x3 * x;
283        let x5 = x4 * x;
284
285        // Sprague coefficients matrix
286        let a0 = y[2];
287        let a1 = (2.0 * y[0] - 16.0 * y[1] + 16.0 * y[3] - 2.0 * y[4]) / 24.0;
288        let a2 = (-y[0] + 16.0 * y[1] - 30.0 * y[2] + 16.0 * y[3] - y[4]) / 24.0;
289        let a3 = (-9.0 * y[0] + 39.0 * y[1] - 70.0 * y[2] + 66.0 * y[3] - 33.0 * y[4] + 7.0 * y[5])
290            / 120.0;
291        let a4 = (13.0 * y[0] - 64.0 * y[1] + 126.0 * y[2] - 124.0 * y[3] + 61.0 * y[4]
292            - 12.0 * y[5])
293            / 120.0;
294        let a5 = (-5.0 * y[0] + 25.0 * y[1] - 50.0 * y[2] + 50.0 * y[3] - 25.0 * y[4] + 5.0 * y[5])
295            / 120.0;
296
297        a0 + a1 * x + a2 * x2 + a3 * x3 + a4 * x4 + a5 * x5
298    }
299
300    /// Convert spectral power distribution to XYZ with specified observer.
301    pub fn to_xyz_emissive_ext(&self, obs: Observer) -> XYZ {
302        const STEP: f32 = 10.0;
303        let (xb, yb, zb) = obs.get_cmfs();
304
305        let mut x = 0.0f32;
306        let mut y = 0.0f32;
307        let mut z = 0.0f32;
308
309        for i in 0..41 {
310            x += self.values[i] * xb[i];
311            y += self.values[i] * yb[i];
312            z += self.values[i] * zb[i];
313        }
314
315        XYZ {
316            x: x * STEP,
317            y: y * STEP,
318            z: z * STEP,
319        }
320    }
321
322    /// Get the raw wavelengths and values as references.
323    /// Used for spectral reconstruction and external processing.
324    pub fn get_wavelength_data(&self) -> (Vec<f32>, Vec<f32>) {
325        (self.wavelengths.clone(), self.values.clone())
326    }
327
328    /// Convert reflectance to XYZ using ASTM E308 weighting factors (D65/2°).
329    /// This is the most accurate method for reflective measurements.
330    ///
331    /// The weighting factors already include:
332    /// - D65 spectral power distribution
333    /// - CIE 1931 2° standard observer CMFs
334    /// - Proper normalization
335    pub fn to_xyz_reflective_2(&self) -> XYZ {
336        let mut x = 0.0f32;
337        let mut y = 0.0f32;
338        let mut z = 0.0f32;
339
340        for i in 0..41 {
341            x += self.values[i] * weighting::WX_D65_2_10[i];
342            y += self.values[i] * weighting::WY_D65_2_10[i];
343            z += self.values[i] * weighting::WZ_D65_2_10[i];
344        }
345
346        // ASTM E308 weights when summed for R=1.0 give ~10.683
347        // Normalize so that Y=100 for a perfect white diffuser
348        let scale = 100.0 / weighting::SUM_WY_D65_2_10;
349
350        XYZ {
351            x: x * scale,
352            y: y * scale,
353            z: z * scale,
354        }
355    }
356
357    /// Convert spectral power distribution to XYZ for emissive sources (2° observer).
358    /// Uses direct integration with CIE CMFs.
359    ///
360    /// # Output Units
361    ///
362    /// The output units depend on how the spectral data was processed:
363    /// - If spectral values are in device-calibrated units (via EEPROM `emis_coef`),
364    ///   the Y value approximates luminance in cd/m² (after proper device calibration).
365    /// - For raw spectral power in W/sr/m²/nm, multiply Y by Km=683 lm/W for cd/m².
366    ///
367    /// Note: The ColorMunki's EEPROM `emis_coef` provides device-specific calibration
368    /// that should produce results comparable to ArgyllCMS when properly applied.
369    pub fn to_xyz_emissive_2(&self) -> XYZ {
370        const STEP: f32 = 10.0; // 10nm wavelength step
371
372        let mut x = 0.0f32;
373        let mut y = 0.0f32;
374        let mut z = 0.0f32;
375
376        for i in 0..41 {
377            x += self.values[i] * X_BAR_2[i];
378            y += self.values[i] * Y_BAR_2[i];
379            z += self.values[i] * Z_BAR_2[i];
380        }
381
382        // Integrate P(λ) * CMF(λ) * Δλ
383        // No additional Km scaling - emis_coef from EEPROM provides calibration
384        XYZ {
385            x: x * STEP,
386            y: y * STEP,
387            z: z * STEP,
388        }
389    }
390
391    /// Convert to XYZ using the 2-degree observer (CIE 1931).
392    /// Legacy method - uses CMF integration (suitable for emissive sources)
393    #[deprecated(
394        since = "0.2.0",
395        note = "Use to_xyz() with appropriate MeasurementMode"
396    )]
397    pub fn to_xyz_2(&self) -> XYZ {
398        self.to_xyz_emissive_2()
399    }
400
401    /// Convert to XYZ using the 10-degree observer (CIE 1964).
402    /// Uses CMF integration (suitable for emissive sources)
403    pub fn to_xyz_10(&self) -> XYZ {
404        const STEP: f32 = 10.0;
405
406        let mut x = 0.0f32;
407        let mut y = 0.0f32;
408        let mut z = 0.0f32;
409
410        for i in 0..41 {
411            x += self.values[i] * X_BAR_10[i];
412            y += self.values[i] * Y_BAR_10[i];
413            z += self.values[i] * Z_BAR_10[i];
414        }
415
416        XYZ {
417            x: x * STEP,
418            y: y * STEP,
419            z: z * STEP,
420        }
421    }
422
423    /// Calculate the normalization constant k for reflectance mode.
424    /// k = 100 / Σ(S(λ) * ȳ(λ) * Δλ)
425    ///
426    /// This is useful when you have raw illuminant SPD and CMF data
427    /// and need to compute the normalization factor dynamically.
428    ///
429    /// # Arguments
430    /// * `illuminant_spd` - Relative spectral power distribution of the illuminant
431    /// * `y_bar` - Y color matching function values
432    /// * `step` - Wavelength step in nm
433    pub fn calculate_k(illuminant_spd: &[f32], y_bar: &[f32], step: f32) -> f32 {
434        let sum_s_y: f32 = illuminant_spd
435            .iter()
436            .zip(y_bar.iter())
437            .map(|(s, yb)| s * yb)
438            .sum();
439        100.0 / (sum_s_y * step)
440    }
441
442    /// Convert the spectral data into a consolidated `MeasurementResult`.
443    /// This performs all standard colorimetric conversions (XYZ, Lab, RGB, CCT)
444    /// using standard settings (D50 for Lab/Print, D65 for Screen/RGB).
445    pub fn to_result(&self) -> MeasurementResult {
446        // Standard Print/Design workflow uses D50
447        let target_illuminant = Illuminant::D50;
448        let observer = Observer::CIE1931_2;
449
450        // 1. Calculate XYZ based on current mode defaults
451        // For reflective, we usually want D50 (ICC standard)
452        // For emissive, we just integrate (native white point)
453        let xyz = if self.mode == MeasurementMode::Reflective {
454            self.to_xyz_ext(target_illuminant, observer)
455        } else {
456            self.to_xyz_ext(Illuminant::D65, observer)
457        };
458
459        // 2. Normalize XYZ (Y=100) -> Y=1.0 for some contexts, but Lab expects Y=100
460        // Our XYZ struct is typically 0..100 range.
461
462        // 3. Calculate Lab (always relative to D50 for ICC compatibility)
463        let wp = target_illuminant.get_white_point(observer);
464
465        // If we measured emissive (e.g. D65 screen), we need to adapt to D50 for Lab
466        // or just use the measured white point as reference.
467        // For SSOT simplicity in this generic result, we use standard D50 Lab.
468        let lab = if self.mode == MeasurementMode::Reflective {
469            xyz.to_lab(wp)
470        } else {
471            // For emissive, Lab is relative to the device's own white point often,
472            // or we adapt to D50. Let's adapt to D50 to be safe for "standard" Lab values.
473            let adapted_xyz = crate::colorimetry::chromatic_adaptation::bradford_adapt(
474                xyz,
475                Illuminant::D65.get_white_point(observer), // Assuming D65 native for now
476                wp,
477            );
478            adapted_xyz.to_lab(wp)
479        };
480
481        // 4. Calculate sRGB (always D65)
482        // We need D65 XYZ for sRGB
483        let srgb_xyz = if self.mode == MeasurementMode::Reflective {
484            // Adapt D50 -> D65
485            crate::colorimetry::chromatic_adaptation::bradford_adapt(
486                xyz,
487                wp,
488                Illuminant::D65.get_white_point(observer),
489            )
490        } else {
491            xyz // Already D65-ish or native
492        };
493        let (r, g, b) = srgb_xyz.to_srgb();
494        let rgb_float = (r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
495
496        // 5. CCT
497        let cct = xyz.to_cct();
498
499        // 6. Calculate CRI
500        // Returns (Ra, R9). We store Ra in the cri field.
501        // This requires the spectrum to be aligned to 380-780nm/10nm.
502        // SpectralData guarantees safe padding so this shouldn't panic.
503        let (ra, _) = crate::colorimetry::metrics::calculate_cri(self);
504
505        MeasurementResult {
506            spectrum: self.clone(),
507            xyz,
508            lab,
509            rgb: rgb_float,
510            cct,
511            cri: Some(ra),
512        }
513    }
514}
515
516impl XYZ {
517    pub fn to_chromaticity(&self) -> (f32, f32) {
518        let sum = self.x + self.y + self.z;
519        if sum < 1e-6 {
520            return (0.3127, 0.3290);
521        } // Default to D65 if zero
522        (self.x / sum, self.y / sum)
523    }
524
525    /// Calculate Correlated Color Temperature (CCT) using McCamy's formula.
526    pub fn to_cct(&self) -> f32 {
527        let (x, y) = self.to_chromaticity();
528        let n = (x - 0.3320) / (0.1858 - y);
529        // McCamy's formula
530        449.0 * n.powi(3) + 3525.0 * n.powi(2) + 6823.3 * n + 5524.33
531    }
532}
533
534impl std::fmt::Display for SpectralData {
535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536        writeln!(f, "Spectral Data (380nm - 730nm, {:?} mode):", self.mode)?;
537        for (w, v) in self.wavelengths.iter().zip(self.values.iter()) {
538            writeln!(f, "  {:.0}nm: {:.6}", w, v)?;
539        }
540        Ok(())
541    }
542}