use crate::constants::{
EXPOSURE_BRIGHTNESS_DARK, EXPOSURE_BRIGHTNESS_GOOD, EXPOSURE_BRIGHTNESS_HIGH,
EXPOSURE_BRIGHTNESS_LOW, EXPOSURE_PIXEL_BRIGHT, EXPOSURE_PIXEL_DARK, QUALITY_SCORE_BLURRY,
QUALITY_SCORE_GOOD, QUALITY_SCORE_SHARP,
};
use crate::types::CameraFrame;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExposureLevel {
Underexposed,
SlightlyDark,
WellExposed,
SlightlyBright,
Overexposed,
}
impl ExposureLevel {
#[must_use]
pub fn from_brightness(brightness: f32) -> Self {
if brightness < EXPOSURE_BRIGHTNESS_LOW {
Self::Underexposed
} else if brightness < EXPOSURE_BRIGHTNESS_DARK {
Self::SlightlyDark
} else if brightness < EXPOSURE_BRIGHTNESS_GOOD {
Self::WellExposed
} else if brightness < EXPOSURE_BRIGHTNESS_HIGH {
Self::SlightlyBright
} else {
Self::Overexposed
}
}
#[must_use]
pub fn quality_score(self) -> f32 {
match self {
Self::WellExposed => QUALITY_SCORE_SHARP, Self::SlightlyDark | Self::SlightlyBright => QUALITY_SCORE_GOOD, Self::Underexposed | Self::Overexposed => QUALITY_SCORE_BLURRY, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExposureMetrics {
pub mean_brightness: f32,
pub brightness_std: f32,
pub histogram: Vec<u32>,
pub dark_pixel_ratio: f32,
pub bright_pixel_ratio: f32,
pub dynamic_range: f32,
pub exposure_level: ExposureLevel,
pub quality_score: f32,
}
pub struct ExposureAnalyzer {
dark_threshold: u8,
bright_threshold: u8,
}
impl Default for ExposureAnalyzer {
fn default() -> Self {
Self {
dark_threshold: EXPOSURE_PIXEL_DARK, bright_threshold: EXPOSURE_PIXEL_BRIGHT, }
}
}
impl ExposureAnalyzer {
pub fn new(dark_threshold: u8, bright_threshold: u8) -> Self {
Self {
dark_threshold,
bright_threshold,
}
}
pub fn analyze_frame(&self, frame: &CameraFrame) -> ExposureMetrics {
let grayscale = Self::rgb_to_luminance(&frame.data, frame.width, frame.height);
let histogram = Self::calculate_histogram(&grayscale);
let mean_brightness = Self::calculate_mean_brightness(&grayscale);
let brightness_std = Self::calculate_brightness_std(&grayscale, mean_brightness);
let dark_pixel_ratio = self.calculate_dark_pixel_ratio(&grayscale);
let bright_pixel_ratio = self.calculate_bright_pixel_ratio(&grayscale);
let dynamic_range = Self::calculate_dynamic_range(&histogram);
let exposure_level = ExposureLevel::from_brightness(mean_brightness);
let quality_score =
Self::calculate_quality_score(exposure_level, brightness_std, dynamic_range);
ExposureMetrics {
mean_brightness,
brightness_std,
histogram,
dark_pixel_ratio,
bright_pixel_ratio,
dynamic_range,
exposure_level,
quality_score,
}
}
fn rgb_to_luminance(rgb_data: &[u8], width: u32, height: u32) -> Vec<u8> {
let mut luminance = Vec::with_capacity((width * height) as usize);
for i in (0..rgb_data.len()).step_by(3) {
if i + 2 >= rgb_data.len() {
break;
}
let r = f32::from(rgb_data[i]);
let g = f32::from(rgb_data[i + 1]);
let b = f32::from(rgb_data[i + 2]);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let y = (0.2126 * r + 0.7152 * g + 0.0722 * b)
.round()
.clamp(0.0, 255.0) as u8;
luminance.push(y);
}
luminance
}
fn calculate_histogram(luminance: &[u8]) -> Vec<u32> {
let mut histogram = vec![0u32; 256];
for &pixel in luminance {
histogram[pixel as usize] += 1;
}
histogram
}
fn calculate_mean_brightness(luminance: &[u8]) -> f32 {
if luminance.is_empty() {
return 0.0;
}
let sum: u64 = luminance.iter().map(|&x| u64::from(x)).sum();
#[allow(clippy::cast_precision_loss)]
let sum_f = sum as f32;
#[allow(clippy::cast_precision_loss)]
let len_f = luminance.len() as f32;
sum_f / (len_f * 255.0)
}
fn calculate_brightness_std(luminance: &[u8], mean: f32) -> f32 {
if luminance.is_empty() {
return 0.0;
}
let mean_255 = mean * 255.0; #[allow(clippy::cast_precision_loss)]
let len_f = luminance.len() as f32;
let variance: f32 = luminance
.iter()
.map(|&x| (f32::from(x) - mean_255).powi(2))
.sum::<f32>()
/ len_f;
variance.sqrt() / 255.0 }
fn calculate_dark_pixel_ratio(&self, luminance: &[u8]) -> f32 {
if luminance.is_empty() {
return 0.0;
}
let dark_count = luminance
.iter()
.filter(|&&x| x < self.dark_threshold)
.count();
#[allow(clippy::cast_precision_loss)]
let dark_f = dark_count as f32;
#[allow(clippy::cast_precision_loss)]
let len_f = luminance.len() as f32;
dark_f / len_f
}
fn calculate_bright_pixel_ratio(&self, luminance: &[u8]) -> f32 {
if luminance.is_empty() {
return 0.0;
}
let bright_count = luminance
.iter()
.filter(|&&x| x > self.bright_threshold)
.count();
#[allow(clippy::cast_precision_loss)]
let bright_f = bright_count as f32;
#[allow(clippy::cast_precision_loss)]
let len_f = luminance.len() as f32;
bright_f / len_f
}
fn calculate_dynamic_range(histogram: &[u32]) -> f32 {
let mut min_value = 255;
let mut max_value = 0;
for (i, &count) in histogram.iter().enumerate() {
if count > 0 {
min_value = i;
break;
}
}
for (i, &count) in histogram.iter().enumerate().rev() {
if count > 0 {
max_value = i;
break;
}
}
if max_value > min_value {
#[allow(clippy::cast_precision_loss)]
let range = (max_value - min_value) as f32;
range / 255.0
} else {
0.0
}
}
#[allow(clippy::similar_names)]
fn calculate_quality_score(
exposure_level: ExposureLevel,
brightness_std: f32,
dynamic_range: f32,
) -> f32 {
let exposure_score = exposure_level.quality_score();
let contrast_score = if brightness_std > 0.15 && brightness_std < 0.35 {
1.0
} else if brightness_std > 0.1 && brightness_std < 0.4 {
0.8
} else {
0.5
};
let range_score = if dynamic_range > 0.7 {
1.0
} else if dynamic_range > 0.5 {
0.8
} else if dynamic_range > 0.3 {
0.6
} else {
0.4
};
(exposure_score * 0.6 + contrast_score * 0.25 + range_score * 0.15).clamp(0.0, 1.0)
}
pub fn is_acceptable_exposure(&self, metrics: &ExposureMetrics) -> bool {
matches!(
metrics.exposure_level,
ExposureLevel::WellExposed
| ExposureLevel::SlightlyDark
| ExposureLevel::SlightlyBright
)
}
pub fn get_exposure_correction(&self, metrics: &ExposureMetrics) -> ExposureCorrection {
match metrics.exposure_level {
ExposureLevel::Underexposed => ExposureCorrection::IncreaseExposure(1.5),
ExposureLevel::SlightlyDark => ExposureCorrection::IncreaseExposure(1.2),
ExposureLevel::WellExposed => ExposureCorrection::NoChange,
ExposureLevel::SlightlyBright => ExposureCorrection::DecreaseExposure(0.8),
ExposureLevel::Overexposed => ExposureCorrection::DecreaseExposure(0.6),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ExposureCorrection {
NoChange,
IncreaseExposure(f32),
DecreaseExposure(f32),
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_frame_with_brightness(width: u32, height: u32, brightness: u8) -> CameraFrame {
let size = (width * height * 3) as usize;
let data = vec![brightness; size];
CameraFrame::new(data, width, height, "test".to_string())
}
#[test]
fn test_exposure_level_from_brightness() {
assert_eq!(
ExposureLevel::from_brightness(0.1),
ExposureLevel::Underexposed
);
assert_eq!(
ExposureLevel::from_brightness(0.3),
ExposureLevel::SlightlyDark
);
assert_eq!(
ExposureLevel::from_brightness(0.5),
ExposureLevel::WellExposed
);
assert_eq!(
ExposureLevel::from_brightness(0.7),
ExposureLevel::SlightlyBright
);
assert_eq!(
ExposureLevel::from_brightness(0.9),
ExposureLevel::Overexposed
);
}
#[test]
fn test_exposure_level_quality_score() {
let epsilon = 1e-10;
assert!((ExposureLevel::WellExposed.quality_score() - 1.0).abs() < epsilon);
assert!((ExposureLevel::SlightlyDark.quality_score() - 0.8).abs() < epsilon);
assert!((ExposureLevel::SlightlyBright.quality_score() - 0.8).abs() < epsilon);
assert!((ExposureLevel::Underexposed.quality_score() - 0.3).abs() < epsilon);
assert!((ExposureLevel::Overexposed.quality_score() - 0.3).abs() < epsilon);
}
#[test]
fn test_exposure_analyzer_creation() {
let analyzer = ExposureAnalyzer::default();
assert_eq!(analyzer.dark_threshold, 30);
assert_eq!(analyzer.bright_threshold, 225);
let custom_analyzer = ExposureAnalyzer::new(20, 240);
assert_eq!(custom_analyzer.dark_threshold, 20);
assert_eq!(custom_analyzer.bright_threshold, 240);
}
#[test]
fn test_rgb_to_luminance() {
let _analyzer = ExposureAnalyzer::default();
let rgb_data = vec![255, 255, 255, 0, 0, 0]; let luminance = ExposureAnalyzer::rgb_to_luminance(&rgb_data, 2, 1);
assert_eq!(luminance.len(), 2);
assert!(luminance[0] > 250); assert!(luminance[1] < 5); }
#[test]
fn test_histogram_calculation() {
let _analyzer = ExposureAnalyzer::default();
let luminance = vec![0, 128, 255, 128]; let histogram = ExposureAnalyzer::calculate_histogram(&luminance);
assert_eq!(histogram.len(), 256);
assert_eq!(histogram[0], 1); assert_eq!(histogram[128], 2); assert_eq!(histogram[255], 1); }
#[test]
fn test_dark_frame_analysis() {
let analyzer = ExposureAnalyzer::default();
let dark_frame = create_test_frame_with_brightness(50, 50, 20);
let metrics = analyzer.analyze_frame(&dark_frame);
assert!(metrics.mean_brightness < 0.2);
assert_eq!(metrics.exposure_level, ExposureLevel::Underexposed);
assert!(metrics.dark_pixel_ratio > 0.5);
}
#[test]
fn test_bright_frame_analysis() {
let analyzer = ExposureAnalyzer::default();
let bright_frame = create_test_frame_with_brightness(50, 50, 240);
let metrics = analyzer.analyze_frame(&bright_frame);
assert!(metrics.mean_brightness > 0.8);
assert_eq!(metrics.exposure_level, ExposureLevel::Overexposed);
assert!(metrics.bright_pixel_ratio > 0.5);
}
#[test]
fn test_well_exposed_frame() {
let analyzer = ExposureAnalyzer::default();
let well_exposed_frame = create_test_frame_with_brightness(50, 50, 128);
let metrics = analyzer.analyze_frame(&well_exposed_frame);
assert!(metrics.mean_brightness > 0.4 && metrics.mean_brightness < 0.6);
assert_eq!(metrics.exposure_level, ExposureLevel::WellExposed);
assert!(analyzer.is_acceptable_exposure(&metrics));
}
#[test]
fn test_exposure_correction() {
let analyzer = ExposureAnalyzer::default();
let dark_metrics = ExposureMetrics {
mean_brightness: 0.1,
brightness_std: 0.05,
histogram: vec![0; 256],
dark_pixel_ratio: 0.8,
bright_pixel_ratio: 0.0,
dynamic_range: 0.2,
exposure_level: ExposureLevel::Underexposed,
quality_score: 0.3,
};
match analyzer.get_exposure_correction(&dark_metrics) {
ExposureCorrection::IncreaseExposure(factor) => {
assert!(factor > 1.0);
}
_ => panic!("Expected IncreaseExposure for dark image"),
}
}
}