#![allow(dead_code)]
#[derive(Debug, Clone)]
pub struct FlareConfig {
pub num_zones: usize,
pub smooth_kernel: usize,
pub min_valid_value: f64,
pub spatial_correction: bool,
pub method: FlareMethod,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlareMethod {
BlackPatch,
SurroundAnalysis,
ContrastTarget,
}
impl Default for FlareConfig {
fn default() -> Self {
Self {
num_zones: 5,
smooth_kernel: 3,
min_valid_value: 0.0,
spatial_correction: true,
method: FlareMethod::BlackPatch,
}
}
}
impl FlareConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn with_num_zones(mut self, zones: usize) -> Self {
self.num_zones = zones;
self
}
#[must_use]
pub const fn with_smooth_kernel(mut self, size: usize) -> Self {
self.smooth_kernel = size;
self
}
#[must_use]
pub const fn with_method(mut self, method: FlareMethod) -> Self {
self.method = method;
self
}
#[must_use]
pub const fn with_spatial_correction(mut self, enable: bool) -> Self {
self.spatial_correction = enable;
self
}
}
#[derive(Debug, Clone, Copy)]
pub struct FlareRgb {
pub r: f64,
pub g: f64,
pub b: f64,
}
impl FlareRgb {
#[must_use]
pub const fn new(r: f64, g: f64, b: f64) -> Self {
Self { r, g, b }
}
#[must_use]
pub const fn zero() -> Self {
Self {
r: 0.0,
g: 0.0,
b: 0.0,
}
}
#[must_use]
pub const fn gray(v: f64) -> Self {
Self { r: v, g: v, b: v }
}
#[must_use]
pub fn luminance(&self) -> f64 {
0.2126 * self.r + 0.7152 * self.g + 0.0722 * self.b
}
#[must_use]
pub fn subtract(&self, other: &Self) -> Self {
Self {
r: (self.r - other.r).max(0.0),
g: (self.g - other.g).max(0.0),
b: (self.b - other.b).max(0.0),
}
}
#[must_use]
pub fn scale(&self, factor: f64) -> Self {
Self {
r: self.r * factor,
g: self.g * factor,
b: self.b * factor,
}
}
#[must_use]
pub fn average(&self, other: &Self) -> Self {
Self {
r: (self.r + other.r) * 0.5,
g: (self.g + other.g) * 0.5,
b: (self.b + other.b) * 0.5,
}
}
}
#[derive(Debug, Clone)]
pub struct FlareEstimate {
pub flare_level: FlareRgb,
pub flare_percentage: f64,
pub measured_contrast_ratio: f64,
pub corrected_contrast_ratio: f64,
}
#[derive(Debug, Clone)]
pub struct FlareMap {
pub width: usize,
pub height: usize,
pub data: Vec<f64>,
}
impl FlareMap {
#[must_use]
pub fn uniform(width: usize, height: usize, flare: &FlareRgb) -> Self {
let mut data = Vec::with_capacity(width * height * 3);
for _y in 0..height {
for _x in 0..width {
data.push(flare.r);
data.push(flare.g);
data.push(flare.b);
}
}
Self {
width,
height,
data,
}
}
#[must_use]
pub fn get(&self, x: usize, y: usize) -> Option<FlareRgb> {
if x < self.width && y < self.height {
let idx = (y * self.width + x) * 3;
Some(FlareRgb::new(
self.data[idx],
self.data[idx + 1],
self.data[idx + 2],
))
} else {
None
}
}
pub fn set(&mut self, x: usize, y: usize, rgb: &FlareRgb) {
if x < self.width && y < self.height {
let idx = (y * self.width + x) * 3;
self.data[idx] = rgb.r;
self.data[idx + 1] = rgb.g;
self.data[idx + 2] = rgb.b;
}
}
}
#[derive(Debug)]
pub struct FlareCorrector {
config: FlareConfig,
}
impl FlareCorrector {
#[must_use]
pub fn new(config: FlareConfig) -> Self {
Self { config }
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(FlareConfig::default())
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn estimate_from_black_patches(
&self,
black_patches: &[FlareRgb],
white_reference: &FlareRgb,
) -> FlareEstimate {
if black_patches.is_empty() {
return FlareEstimate {
flare_level: FlareRgb::zero(),
flare_percentage: 0.0,
measured_contrast_ratio: f64::INFINITY,
corrected_contrast_ratio: f64::INFINITY,
};
}
let n = black_patches.len() as f64;
let avg_r: f64 = black_patches.iter().map(|p| p.r).sum::<f64>() / n;
let avg_g: f64 = black_patches.iter().map(|p| p.g).sum::<f64>() / n;
let avg_b: f64 = black_patches.iter().map(|p| p.b).sum::<f64>() / n;
let flare = FlareRgb::new(avg_r, avg_g, avg_b);
let flare_lum = flare.luminance();
let white_lum = white_reference.luminance().max(1e-10);
let flare_pct = (flare_lum / white_lum) * 100.0;
let measured_cr = if flare_lum > 1e-10 {
white_lum / flare_lum
} else {
f64::INFINITY
};
let corrected_black = flare_lum; let corrected_white = (white_lum - flare_lum).max(1e-10);
let corrected_cr = if corrected_black > 1e-10 {
corrected_white / corrected_black
} else {
corrected_white / 1e-10
};
FlareEstimate {
flare_level: flare,
flare_percentage: flare_pct,
measured_contrast_ratio: measured_cr,
corrected_contrast_ratio: corrected_cr,
}
}
#[must_use]
pub fn estimate_from_contrast_target(
&self,
black_measured: &FlareRgb,
white_measured: &FlareRgb,
target_contrast_ratio: f64,
) -> FlareEstimate {
let true_black_lum = white_measured.luminance() / target_contrast_ratio.max(1.0);
let measured_black_lum = black_measured.luminance();
let flare_lum = (measured_black_lum - true_black_lum).max(0.0);
let scale = if measured_black_lum > 1e-10 {
flare_lum / measured_black_lum
} else {
0.0
};
let flare = FlareRgb::new(
black_measured.r * scale,
black_measured.g * scale,
black_measured.b * scale,
);
let white_lum = white_measured.luminance().max(1e-10);
let flare_pct = (flare_lum / white_lum) * 100.0;
let measured_cr = if measured_black_lum > 1e-10 {
white_lum / measured_black_lum
} else {
f64::INFINITY
};
FlareEstimate {
flare_level: flare,
flare_percentage: flare_pct,
measured_contrast_ratio: measured_cr,
corrected_contrast_ratio: target_contrast_ratio,
}
}
#[must_use]
pub fn correct_measurements(measurements: &[FlareRgb], flare: &FlareRgb) -> Vec<FlareRgb> {
measurements.iter().map(|m| m.subtract(flare)).collect()
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn generate_radial_flare_map(
width: usize,
height: usize,
center_flare: &FlareRgb,
edge_flare: &FlareRgb,
) -> FlareMap {
let cx = width as f64 / 2.0;
let cy = height as f64 / 2.0;
let max_r = (cx * cx + cy * cy).sqrt();
let mut map = FlareMap::uniform(width, height, center_flare);
for y in 0..height {
for x in 0..width {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
let r = (dx * dx + dy * dy).sqrt();
let t = (r / max_r).min(1.0);
let flare = FlareRgb::new(
center_flare.r * (1.0 - t) + edge_flare.r * t,
center_flare.g * (1.0 - t) + edge_flare.g * t,
center_flare.b * (1.0 - t) + edge_flare.b * t,
);
map.set(x, y, &flare);
}
}
map
}
#[must_use]
pub const fn config(&self) -> &FlareConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let cfg = FlareConfig::default();
assert_eq!(cfg.num_zones, 5);
assert_eq!(cfg.method, FlareMethod::BlackPatch);
assert!(cfg.spatial_correction);
}
#[test]
fn test_config_builder() {
let cfg = FlareConfig::new()
.with_num_zones(8)
.with_smooth_kernel(5)
.with_method(FlareMethod::ContrastTarget)
.with_spatial_correction(false);
assert_eq!(cfg.num_zones, 8);
assert_eq!(cfg.smooth_kernel, 5);
assert_eq!(cfg.method, FlareMethod::ContrastTarget);
assert!(!cfg.spatial_correction);
}
#[test]
fn test_flare_rgb_luminance() {
let white = FlareRgb::new(1.0, 1.0, 1.0);
let lum = white.luminance();
assert!((lum - 1.0).abs() < 1e-4);
let black = FlareRgb::zero();
assert!((black.luminance() - 0.0).abs() < 1e-10);
}
#[test]
fn test_flare_rgb_subtract() {
let a = FlareRgb::new(0.5, 0.5, 0.5);
let b = FlareRgb::new(0.1, 0.1, 0.1);
let result = a.subtract(&b);
assert!((result.r - 0.4).abs() < 1e-10);
assert!((result.g - 0.4).abs() < 1e-10);
}
#[test]
fn test_flare_rgb_subtract_clamp() {
let a = FlareRgb::new(0.05, 0.05, 0.05);
let b = FlareRgb::new(0.1, 0.1, 0.1);
let result = a.subtract(&b);
assert!((result.r - 0.0).abs() < 1e-10);
}
#[test]
fn test_flare_rgb_scale() {
let c = FlareRgb::new(0.5, 0.4, 0.3);
let scaled = c.scale(2.0);
assert!((scaled.r - 1.0).abs() < 1e-10);
assert!((scaled.g - 0.8).abs() < 1e-10);
}
#[test]
fn test_flare_rgb_average() {
let a = FlareRgb::new(0.0, 0.0, 0.0);
let b = FlareRgb::new(1.0, 1.0, 1.0);
let avg = a.average(&b);
assert!((avg.r - 0.5).abs() < 1e-10);
}
#[test]
fn test_estimate_from_black_patches() {
let corrector = FlareCorrector::with_defaults();
let blacks = vec![
FlareRgb::new(0.02, 0.02, 0.02),
FlareRgb::new(0.03, 0.03, 0.03),
];
let white = FlareRgb::new(1.0, 1.0, 1.0);
let est = corrector.estimate_from_black_patches(&blacks, &white);
assert!(est.flare_percentage > 0.0);
assert!(est.flare_level.r > 0.0);
assert!(est.measured_contrast_ratio > 1.0);
}
#[test]
fn test_estimate_from_empty_patches() {
let corrector = FlareCorrector::with_defaults();
let est = corrector.estimate_from_black_patches(&[], &FlareRgb::new(1.0, 1.0, 1.0));
assert!((est.flare_percentage - 0.0).abs() < 1e-10);
}
#[test]
fn test_estimate_from_contrast_target() {
let corrector = FlareCorrector::with_defaults();
let black = FlareRgb::new(0.01, 0.01, 0.01);
let white = FlareRgb::new(1.0, 1.0, 1.0);
let est = corrector.estimate_from_contrast_target(&black, &white, 1000.0);
assert!(est.flare_percentage > 0.0);
assert!((est.corrected_contrast_ratio - 1000.0).abs() < 1e-10);
}
#[test]
fn test_correct_measurements() {
let measurements = vec![FlareRgb::new(0.5, 0.5, 0.5), FlareRgb::new(0.3, 0.3, 0.3)];
let flare = FlareRgb::new(0.02, 0.02, 0.02);
let corrected = FlareCorrector::correct_measurements(&measurements, &flare);
assert_eq!(corrected.len(), 2);
assert!((corrected[0].r - 0.48).abs() < 1e-10);
assert!((corrected[1].r - 0.28).abs() < 1e-10);
}
#[test]
fn test_flare_map_uniform() {
let flare = FlareRgb::new(0.01, 0.01, 0.01);
let map = FlareMap::uniform(10, 10, &flare);
let val = map.get(5, 5).expect("expected key to exist");
assert!((val.r - 0.01).abs() < 1e-10);
}
#[test]
fn test_flare_map_set_get() {
let mut map = FlareMap::uniform(10, 10, &FlareRgb::zero());
map.set(3, 4, &FlareRgb::new(0.5, 0.6, 0.7));
let val = map.get(3, 4).expect("expected key to exist");
assert!((val.r - 0.5).abs() < 1e-10);
assert!((val.g - 0.6).abs() < 1e-10);
assert!(map.get(20, 20).is_none());
}
#[test]
fn test_radial_flare_map() {
let center = FlareRgb::new(0.01, 0.01, 0.01);
let edge = FlareRgb::new(0.05, 0.05, 0.05);
let map = FlareCorrector::generate_radial_flare_map(20, 20, ¢er, &edge);
let c = map.get(10, 10).expect("expected key to exist");
assert!(c.r < 0.03);
let corner = map.get(0, 0).expect("expected key to exist");
assert!(corner.r > c.r);
}
}