use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
use imgref::ImgVec;
use rgb::{RGB8, RGBA8};
use crate::error::Result;
use crate::eval::report::{CodecResult, CorpusReport, ImageReport};
use crate::metrics::dssim::rgb8_to_dssim_image;
use crate::metrics::{MetricConfig, MetricResult, calculate_psnr};
use crate::viewing::ViewingCondition;
#[derive(Clone)]
pub enum ImageData {
Rgb8(ImgVec<RGB8>),
Rgba8(ImgVec<RGBA8>),
RgbSlice {
data: Vec<u8>,
width: usize,
height: usize,
},
RgbaSlice {
data: Vec<u8>,
width: usize,
height: usize,
},
RgbSliceWithIcc {
data: Vec<u8>,
width: usize,
height: usize,
icc_profile: Vec<u8>,
},
}
impl ImageData {
#[must_use]
pub fn width(&self) -> usize {
match self {
Self::Rgb8(img) => img.width(),
Self::Rgba8(img) => img.width(),
Self::RgbSlice { width, .. }
| Self::RgbaSlice { width, .. }
| Self::RgbSliceWithIcc { width, .. } => *width,
}
}
#[must_use]
pub fn height(&self) -> usize {
match self {
Self::Rgb8(img) => img.height(),
Self::Rgba8(img) => img.height(),
Self::RgbSlice { height, .. }
| Self::RgbaSlice { height, .. }
| Self::RgbSliceWithIcc { height, .. } => *height,
}
}
#[must_use]
pub fn to_rgb8_vec(&self) -> Vec<u8> {
match self {
Self::Rgb8(img) => img.pixels().flat_map(|p| [p.r, p.g, p.b]).collect(),
Self::Rgba8(img) => img.pixels().flat_map(|p| [p.r, p.g, p.b]).collect(),
Self::RgbSlice { data, .. } | Self::RgbSliceWithIcc { data, .. } => data.clone(),
Self::RgbaSlice {
data,
width,
height,
} => {
let mut rgb = Vec::with_capacity(width * height * 3);
for chunk in data.chunks_exact(4) {
rgb.push(chunk[0]);
rgb.push(chunk[1]);
rgb.push(chunk[2]);
}
rgb
}
}
}
#[must_use]
pub fn icc_profile(&self) -> Option<&[u8]> {
match self {
Self::RgbSliceWithIcc { icc_profile, .. } => Some(icc_profile),
_ => None,
}
}
#[must_use]
pub fn color_profile(&self) -> crate::metrics::ColorProfile {
match self {
Self::RgbSliceWithIcc { icc_profile, .. } => {
crate::metrics::ColorProfile::Icc(icc_profile.clone())
}
_ => crate::metrics::ColorProfile::Srgb,
}
}
pub fn to_rgb8_srgb(&self) -> crate::error::Result<Vec<u8>> {
let rgb = self.to_rgb8_vec();
let profile = self.color_profile();
crate::metrics::transform_to_srgb(&rgb, &profile)
}
}
#[derive(Debug, Clone)]
pub struct EncodeRequest {
pub quality: f64,
pub params: HashMap<String, String>,
}
impl EncodeRequest {
#[must_use]
pub fn new(quality: f64) -> Self {
Self {
quality,
params: HashMap::new(),
}
}
#[must_use]
pub fn with_param(mut self, key: &str, value: &str) -> Self {
self.params.insert(key.to_string(), value.to_string());
self
}
}
pub type EncodeFn = Box<dyn Fn(&ImageData, &EncodeRequest) -> Result<Vec<u8>> + Send + Sync>;
pub type DecodeFn = Box<dyn Fn(&[u8]) -> Result<ImageData> + Send + Sync>;
#[derive(Debug, Clone)]
pub struct EvalConfig {
pub report_dir: PathBuf,
pub cache_dir: Option<PathBuf>,
pub viewing: ViewingCondition,
pub metrics: MetricConfig,
pub quality_levels: Vec<f64>,
}
impl EvalConfig {
#[must_use]
pub fn builder() -> EvalConfigBuilder {
EvalConfigBuilder::default()
}
}
#[derive(Debug, Default)]
pub struct EvalConfigBuilder {
report_dir: Option<PathBuf>,
cache_dir: Option<PathBuf>,
viewing: Option<ViewingCondition>,
metrics: Option<MetricConfig>,
quality_levels: Option<Vec<f64>>,
}
impl EvalConfigBuilder {
#[must_use]
pub fn report_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.report_dir = Some(path.into());
self
}
#[must_use]
pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.cache_dir = Some(path.into());
self
}
#[must_use]
pub fn viewing(mut self, viewing: ViewingCondition) -> Self {
self.viewing = Some(viewing);
self
}
#[must_use]
pub fn metrics(mut self, metrics: MetricConfig) -> Self {
self.metrics = Some(metrics);
self
}
#[must_use]
pub fn quality_levels(mut self, levels: Vec<f64>) -> Self {
self.quality_levels = Some(levels);
self
}
#[must_use]
pub fn build(self) -> EvalConfig {
EvalConfig {
report_dir: self.report_dir.expect("report_dir is required"),
cache_dir: self.cache_dir,
viewing: self.viewing.unwrap_or_default(),
metrics: self.metrics.unwrap_or_else(MetricConfig::all),
quality_levels: self
.quality_levels
.unwrap_or_else(|| vec![50.0, 60.0, 70.0, 80.0, 85.0, 90.0, 95.0]),
}
}
}
struct CodecEntry {
id: String,
version: String,
encode: EncodeFn,
decode: Option<DecodeFn>,
}
pub struct EvalSession {
config: EvalConfig,
codecs: Vec<CodecEntry>,
}
impl EvalSession {
#[must_use]
pub fn new(config: EvalConfig) -> Self {
Self {
config,
codecs: Vec::new(),
}
}
pub fn add_codec(&mut self, id: &str, version: &str, encode: EncodeFn) -> &mut Self {
self.codecs.push(CodecEntry {
id: id.to_string(),
version: version.to_string(),
encode,
decode: None,
});
self
}
pub fn add_codec_with_decode(
&mut self,
id: &str,
version: &str,
encode: EncodeFn,
decode: DecodeFn,
) -> &mut Self {
self.codecs.push(CodecEntry {
id: id.to_string(),
version: version.to_string(),
encode,
decode: Some(decode),
});
self
}
#[must_use]
pub fn codec_count(&self) -> usize {
self.codecs.len()
}
pub fn evaluate_image(&self, name: &str, image: ImageData) -> Result<ImageReport> {
let width = image.width() as u32;
let height = image.height() as u32;
let mut report = ImageReport::new(name.to_string(), width, height);
let reference_rgb = image.to_rgb8_vec();
for codec in &self.codecs {
for &quality in &self.config.quality_levels {
let request = EncodeRequest::new(quality);
let start = Instant::now();
let encoded = (codec.encode)(&image, &request)?;
let encode_time = start.elapsed();
let metrics = if let Some(ref decode) = codec.decode {
let start = Instant::now();
let decoded = decode(&encoded)?;
let decode_time = start.elapsed();
let decoded_rgb = decoded.to_rgb8_srgb()?;
let metrics =
self.calculate_metrics(&reference_rgb, &decoded_rgb, width, height)?;
report.results.push(CodecResult {
codec_id: codec.id.clone(),
codec_version: codec.version.clone(),
quality,
file_size: encoded.len(),
bits_per_pixel: (encoded.len() * 8) as f64 / (width as f64 * height as f64),
encode_time,
decode_time: Some(decode_time),
metrics: metrics.clone(),
perception: metrics.perception_level(),
cached_path: None,
codec_params: request.params,
});
continue;
} else {
MetricResult::default()
};
report.results.push(CodecResult {
codec_id: codec.id.clone(),
codec_version: codec.version.clone(),
quality,
file_size: encoded.len(),
bits_per_pixel: (encoded.len() * 8) as f64 / (width as f64 * height as f64),
encode_time,
decode_time: None,
metrics,
perception: None,
cached_path: None,
codec_params: request.params,
});
}
}
Ok(report)
}
fn calculate_metrics(
&self,
reference: &[u8],
test: &[u8],
width: u32,
height: u32,
) -> Result<MetricResult> {
let mut result = MetricResult::default();
let reference_for_metrics: std::borrow::Cow<'_, [u8]> = if self.config.metrics.xyb_roundtrip
{
std::borrow::Cow::Owned(crate::metrics::xyb_roundtrip(
reference,
width as usize,
height as usize,
))
} else {
std::borrow::Cow::Borrowed(reference)
};
if self.config.metrics.psnr {
result.psnr = Some(calculate_psnr(
&reference_for_metrics,
test,
width as usize,
height as usize,
));
}
if self.config.metrics.dssim {
let ref_img =
rgb8_to_dssim_image(&reference_for_metrics, width as usize, height as usize);
let test_img = rgb8_to_dssim_image(test, width as usize, height as usize);
result.dssim = Some(crate::metrics::dssim::calculate_dssim(
&ref_img,
&test_img,
&self.config.viewing,
)?);
}
if self.config.metrics.ssimulacra2 {
result.ssimulacra2 = Some(crate::metrics::ssimulacra2::calculate_ssimulacra2(
&reference_for_metrics,
test,
width as usize,
height as usize,
)?);
}
if self.config.metrics.butteraugli {
result.butteraugli = Some(crate::metrics::butteraugli::calculate_butteraugli(
&reference_for_metrics,
test,
width as usize,
height as usize,
)?);
}
Ok(result)
}
pub fn write_image_report(&self, report: &ImageReport) -> Result<()> {
std::fs::create_dir_all(&self.config.report_dir)?;
let json_path = self.config.report_dir.join(format!("{}.json", report.name));
let json = serde_json::to_string_pretty(report)?;
std::fs::write(json_path, json)?;
Ok(())
}
pub fn write_corpus_report(&self, report: &CorpusReport) -> Result<()> {
std::fs::create_dir_all(&self.config.report_dir)?;
let json_path = self.config.report_dir.join(format!("{}.json", report.name));
let json = serde_json::to_string_pretty(report)?;
std::fs::write(json_path, json)?;
let csv_path = self.config.report_dir.join(format!("{}.csv", report.name));
self.write_csv_summary(report, &csv_path)?;
Ok(())
}
fn write_csv_summary(&self, report: &CorpusReport, path: &Path) -> Result<()> {
let mut wtr = csv::Writer::from_path(path)?;
wtr.write_record([
"image",
"codec",
"version",
"quality",
"file_size",
"bpp",
"encode_ms",
"decode_ms",
"dssim",
"ssimulacra2",
"butteraugli",
"psnr",
"perception",
])?;
for img in &report.images {
for result in &img.results {
wtr.write_record([
&img.name,
&result.codec_id,
&result.codec_version,
&result.quality.to_string(),
&result.file_size.to_string(),
&format!("{:.4}", result.bits_per_pixel),
&result.encode_time.as_millis().to_string(),
&result
.decode_time
.map_or(String::new(), |d| d.as_millis().to_string()),
&result
.metrics
.dssim
.map_or(String::new(), |d| format!("{:.6}", d)),
&result
.metrics
.ssimulacra2
.map_or(String::new(), |s| format!("{:.2}", s)),
&result
.metrics
.butteraugli
.map_or(String::new(), |b| format!("{:.4}", b)),
&result
.metrics
.psnr
.map_or(String::new(), |p| format!("{:.2}", p)),
&result
.perception
.map_or(String::new(), |p| p.code().to_string()),
])?;
}
}
wtr.flush()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_image(width: usize, height: usize) -> ImageData {
let data: Vec<u8> = (0..width * height * 3).map(|i| (i % 256) as u8).collect();
ImageData::RgbSlice {
data,
width,
height,
}
}
#[test]
fn test_image_data_dimensions() {
let img = create_test_image(100, 50);
assert_eq!(img.width(), 100);
assert_eq!(img.height(), 50);
}
#[test]
fn test_encode_request() {
let req = EncodeRequest::new(80.0).with_param("subsampling", "4:2:0");
assert!((req.quality - 80.0).abs() < f64::EPSILON);
assert_eq!(req.params.get("subsampling"), Some(&"4:2:0".to_string()));
}
#[test]
fn test_eval_config_builder() {
let config = EvalConfig::builder()
.report_dir("/tmp/reports")
.cache_dir("/tmp/cache")
.viewing(ViewingCondition::laptop())
.quality_levels(vec![50.0, 75.0, 90.0])
.build();
assert_eq!(config.report_dir, PathBuf::from("/tmp/reports"));
assert_eq!(config.cache_dir, Some(PathBuf::from("/tmp/cache")));
assert!((config.viewing.acuity_ppd - 60.0).abs() < f64::EPSILON);
assert_eq!(config.quality_levels.len(), 3);
}
#[test]
fn test_session_add_codec() {
let config = EvalConfig::builder().report_dir("/tmp/test").build();
let mut session = EvalSession::new(config);
session.add_codec("test", "1.0", Box::new(|_, _| Ok(vec![0u8; 100])));
assert_eq!(session.codec_count(), 1);
}
}