#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Illuminant {
D50,
D55,
D65,
D75,
A,
B,
C,
F2,
F7,
F11,
}
impl Illuminant {
#[must_use]
pub const fn color_temperature_k(self) -> u32 {
match self {
Self::D50 => 5000,
Self::D55 => 5500,
Self::D65 => 6500,
Self::D75 => 7500,
Self::A => 2856,
Self::B => 4874,
Self::C => 6774,
Self::F2 => 4200,
Self::F7 => 6500,
Self::F11 => 4000,
}
}
#[must_use]
pub const fn xy_chromaticity(self) -> (f64, f64) {
match self {
Self::D50 => (0.3457, 0.3585),
Self::D55 => (0.3324, 0.3474),
Self::D65 => (0.3127, 0.3290),
Self::D75 => (0.2990, 0.3149),
Self::A => (0.4476, 0.4074),
Self::B => (0.3484, 0.3516),
Self::C => (0.3101, 0.3162),
Self::F2 => (0.3721, 0.3751),
Self::F7 => (0.3129, 0.3292),
Self::F11 => (0.3805, 0.3769),
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct WhiteBalanceMatrix {
pub gains: [f32; 3],
}
impl WhiteBalanceMatrix {
#[must_use]
pub const fn identity() -> Self {
Self {
gains: [1.0, 1.0, 1.0],
}
}
#[must_use]
pub fn apply(&self, pixel: [f32; 3]) -> [f32; 3] {
[
pixel[0] * self.gains[0],
pixel[1] * self.gains[1],
pixel[2] * self.gains[2],
]
}
#[must_use]
pub fn normalize(&self) -> Self {
let g = self.gains[1];
if g.abs() < f32::EPSILON {
return self.clone();
}
Self {
gains: [self.gains[0] / g, 1.0, self.gains[2] / g],
}
}
}
#[must_use]
pub fn compute_wb_from_patch(reference: [f32; 3], measured: [f32; 3]) -> WhiteBalanceMatrix {
let gain = |r: f32, m: f32| {
if m > f32::EPSILON {
r / m
} else {
1.0
}
};
WhiteBalanceMatrix {
gains: [
gain(reference[0], measured[0]),
gain(reference[1], measured[1]),
gain(reference[2], measured[2]),
],
}
}
#[must_use]
pub fn grey_world_wb(pixels: &[[f32; 3]]) -> WhiteBalanceMatrix {
if pixels.is_empty() {
return WhiteBalanceMatrix::identity();
}
let n = pixels.len() as f64;
let mut sum = [0.0f64; 3];
for px in pixels {
sum[0] += f64::from(px[0]);
sum[1] += f64::from(px[1]);
sum[2] += f64::from(px[2]);
}
let mean = [sum[0] / n, sum[1] / n, sum[2] / n];
let overall = (mean[0] + mean[1] + mean[2]) / 3.0;
let gain = |m: f64| {
if m > 1e-10 {
(overall / m) as f32
} else {
1.0
}
};
WhiteBalanceMatrix {
gains: [gain(mean[0]), gain(mean[1]), gain(mean[2])],
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct WbCalibrationReport {
pub target_illuminant: Illuminant,
pub matrix: WhiteBalanceMatrix,
pub delta_e: f32,
}
impl WbCalibrationReport {
#[must_use]
pub fn is_accurate(&self) -> bool {
self.delta_e < 3.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_illuminant_d65_temperature() {
assert_eq!(Illuminant::D65.color_temperature_k(), 6500);
}
#[test]
fn test_illuminant_a_temperature() {
assert_eq!(Illuminant::A.color_temperature_k(), 2856);
}
#[test]
fn test_illuminant_f11_temperature() {
assert_eq!(Illuminant::F11.color_temperature_k(), 4000);
}
#[test]
fn test_illuminant_d65_chromaticity() {
let (x, y) = Illuminant::D65.xy_chromaticity();
assert!((x - 0.3127).abs() < 1e-4, "x={x}");
assert!((y - 0.3290).abs() < 1e-4, "y={y}");
}
#[test]
fn test_illuminant_a_chromaticity_warm() {
let (x, _) = Illuminant::A.xy_chromaticity();
assert!(
x > 0.4,
"Illuminant A should have warm x chromaticity, got {x}"
);
}
#[test]
fn test_wb_matrix_identity() {
let m = WhiteBalanceMatrix::identity();
assert_eq!(m.gains, [1.0, 1.0, 1.0]);
}
#[test]
fn test_wb_matrix_apply_identity() {
let m = WhiteBalanceMatrix::identity();
let pixel = [0.5, 0.6, 0.7];
let out = m.apply(pixel);
assert!((out[0] - 0.5).abs() < 1e-6);
assert!((out[1] - 0.6).abs() < 1e-6);
assert!((out[2] - 0.7).abs() < 1e-6);
}
#[test]
fn test_wb_matrix_apply_scales_channels() {
let m = WhiteBalanceMatrix {
gains: [2.0, 1.0, 0.5],
};
let out = m.apply([1.0, 1.0, 1.0]);
assert!((out[0] - 2.0).abs() < 1e-6);
assert!((out[1] - 1.0).abs() < 1e-6);
assert!((out[2] - 0.5).abs() < 1e-6);
}
#[test]
fn test_wb_matrix_normalize_green_to_one() {
let m = WhiteBalanceMatrix {
gains: [3.0, 2.0, 1.0],
};
let n = m.normalize();
assert!((n.gains[0] - 1.5).abs() < 1e-6, "R gain={}", n.gains[0]);
assert!((n.gains[1] - 1.0).abs() < 1e-6, "G gain={}", n.gains[1]);
assert!((n.gains[2] - 0.5).abs() < 1e-6, "B gain={}", n.gains[2]);
}
#[test]
fn test_wb_matrix_normalize_zero_green_returns_self() {
let m = WhiteBalanceMatrix {
gains: [1.0, 0.0, 1.0],
};
let n = m.normalize();
assert_eq!(n.gains, m.gains);
}
#[test]
fn test_compute_wb_from_patch_basic() {
let reference = [1.0_f32, 1.0, 1.0];
let measured = [0.5_f32, 1.0, 2.0];
let m = compute_wb_from_patch(reference, measured);
assert!((m.gains[0] - 2.0).abs() < 1e-5, "R gain={}", m.gains[0]);
assert!((m.gains[1] - 1.0).abs() < 1e-5, "G gain={}", m.gains[1]);
assert!((m.gains[2] - 0.5).abs() < 1e-5, "B gain={}", m.gains[2]);
}
#[test]
fn test_compute_wb_from_patch_zero_measured_channel() {
let reference = [1.0_f32, 1.0, 1.0];
let measured = [0.0_f32, 1.0, 1.0];
let m = compute_wb_from_patch(reference, measured);
assert!((m.gains[0] - 1.0).abs() < 1e-5);
}
#[test]
fn test_grey_world_wb_neutral_image() {
let pixels: Vec<[f32; 3]> = vec![[0.5, 0.5, 0.5]; 100];
let m = grey_world_wb(&pixels);
for g in m.gains {
assert!((g - 1.0).abs() < 1e-5, "Expected 1.0, got {g}");
}
}
#[test]
fn test_grey_world_wb_red_dominant() {
let pixels: Vec<[f32; 3]> = vec![[1.0, 0.5, 0.5]; 100];
let m = grey_world_wb(&pixels);
assert!(
m.gains[0] < 1.0,
"Red gain should be < 1.0 for red-dominant image"
);
assert!(m.gains[1] > m.gains[0], "Green gain should exceed red gain");
}
#[test]
fn test_grey_world_wb_empty_pixels_returns_identity() {
let m = grey_world_wb(&[]);
assert_eq!(m.gains, [1.0, 1.0, 1.0]);
}
#[test]
fn test_wb_report_accurate_below_threshold() {
let report = WbCalibrationReport {
target_illuminant: Illuminant::D65,
matrix: WhiteBalanceMatrix::identity(),
delta_e: 1.5,
};
assert!(report.is_accurate());
}
#[test]
fn test_wb_report_inaccurate_above_threshold() {
let report = WbCalibrationReport {
target_illuminant: Illuminant::D65,
matrix: WhiteBalanceMatrix::identity(),
delta_e: 5.0,
};
assert!(!report.is_accurate());
}
}