1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::time::Instant;
10
11use imgref::ImgVec;
12use rgb::{RGB8, RGBA8};
13
14use crate::error::Result;
15use crate::eval::report::{CodecResult, CorpusReport, ImageReport};
16use crate::metrics::dssim::rgb8_to_dssim_image;
17use crate::metrics::{MetricConfig, MetricResult, calculate_psnr};
18use crate::viewing::ViewingCondition;
19
20#[derive(Clone)]
25pub enum ImageData {
26 Rgb8(ImgVec<RGB8>),
28
29 Rgba8(ImgVec<RGBA8>),
31
32 RgbSlice {
34 data: Vec<u8>,
36 width: usize,
38 height: usize,
40 },
41
42 RgbaSlice {
44 data: Vec<u8>,
46 width: usize,
48 height: usize,
50 },
51
52 RgbSliceWithIcc {
57 data: Vec<u8>,
59 width: usize,
61 height: usize,
63 icc_profile: Vec<u8>,
65 },
66}
67
68impl ImageData {
69 #[must_use]
71 pub fn width(&self) -> usize {
72 match self {
73 Self::Rgb8(img) => img.width(),
74 Self::Rgba8(img) => img.width(),
75 Self::RgbSlice { width, .. }
76 | Self::RgbaSlice { width, .. }
77 | Self::RgbSliceWithIcc { width, .. } => *width,
78 }
79 }
80
81 #[must_use]
83 pub fn height(&self) -> usize {
84 match self {
85 Self::Rgb8(img) => img.height(),
86 Self::Rgba8(img) => img.height(),
87 Self::RgbSlice { height, .. }
88 | Self::RgbaSlice { height, .. }
89 | Self::RgbSliceWithIcc { height, .. } => *height,
90 }
91 }
92
93 #[must_use]
98 pub fn to_rgb8_vec(&self) -> Vec<u8> {
99 match self {
100 Self::Rgb8(img) => img.pixels().flat_map(|p| [p.r, p.g, p.b]).collect(),
101 Self::Rgba8(img) => img.pixels().flat_map(|p| [p.r, p.g, p.b]).collect(),
102 Self::RgbSlice { data, .. } | Self::RgbSliceWithIcc { data, .. } => data.clone(),
103 Self::RgbaSlice {
104 data,
105 width,
106 height,
107 } => {
108 let mut rgb = Vec::with_capacity(width * height * 3);
109 for chunk in data.chunks_exact(4) {
110 rgb.push(chunk[0]);
111 rgb.push(chunk[1]);
112 rgb.push(chunk[2]);
113 }
114 rgb
115 }
116 }
117 }
118
119 #[must_use]
121 pub fn icc_profile(&self) -> Option<&[u8]> {
122 match self {
123 Self::RgbSliceWithIcc { icc_profile, .. } => Some(icc_profile),
124 _ => None,
125 }
126 }
127
128 #[must_use]
130 pub fn color_profile(&self) -> crate::metrics::ColorProfile {
131 match self {
132 Self::RgbSliceWithIcc { icc_profile, .. } => {
133 crate::metrics::ColorProfile::Icc(icc_profile.clone())
134 }
135 _ => crate::metrics::ColorProfile::Srgb,
136 }
137 }
138
139 pub fn to_rgb8_srgb(&self) -> crate::error::Result<Vec<u8>> {
144 let rgb = self.to_rgb8_vec();
145 let profile = self.color_profile();
146 crate::metrics::transform_to_srgb(&rgb, &profile)
147 }
148}
149
150#[derive(Debug, Clone)]
152pub struct EncodeRequest {
153 pub quality: f64,
155
156 pub params: HashMap<String, String>,
158}
159
160impl EncodeRequest {
161 #[must_use]
163 pub fn new(quality: f64) -> Self {
164 Self {
165 quality,
166 params: HashMap::new(),
167 }
168 }
169
170 #[must_use]
172 pub fn with_param(mut self, key: &str, value: &str) -> Self {
173 self.params.insert(key.to_string(), value.to_string());
174 self
175 }
176}
177
178pub type EncodeFn = Box<dyn Fn(&ImageData, &EncodeRequest) -> Result<Vec<u8>> + Send + Sync>;
182
183pub type DecodeFn = Box<dyn Fn(&[u8]) -> Result<ImageData> + Send + Sync>;
187
188#[derive(Debug, Clone)]
190pub struct EvalConfig {
191 pub report_dir: PathBuf,
193
194 pub cache_dir: Option<PathBuf>,
196
197 pub viewing: ViewingCondition,
199
200 pub metrics: MetricConfig,
202
203 pub quality_levels: Vec<f64>,
205}
206
207impl EvalConfig {
208 #[must_use]
210 pub fn builder() -> EvalConfigBuilder {
211 EvalConfigBuilder::default()
212 }
213}
214
215#[derive(Debug, Default)]
217pub struct EvalConfigBuilder {
218 report_dir: Option<PathBuf>,
219 cache_dir: Option<PathBuf>,
220 viewing: Option<ViewingCondition>,
221 metrics: Option<MetricConfig>,
222 quality_levels: Option<Vec<f64>>,
223}
224
225impl EvalConfigBuilder {
226 #[must_use]
228 pub fn report_dir(mut self, path: impl Into<PathBuf>) -> Self {
229 self.report_dir = Some(path.into());
230 self
231 }
232
233 #[must_use]
235 pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
236 self.cache_dir = Some(path.into());
237 self
238 }
239
240 #[must_use]
242 pub fn viewing(mut self, viewing: ViewingCondition) -> Self {
243 self.viewing = Some(viewing);
244 self
245 }
246
247 #[must_use]
249 pub fn metrics(mut self, metrics: MetricConfig) -> Self {
250 self.metrics = Some(metrics);
251 self
252 }
253
254 #[must_use]
256 pub fn quality_levels(mut self, levels: Vec<f64>) -> Self {
257 self.quality_levels = Some(levels);
258 self
259 }
260
261 #[must_use]
267 pub fn build(self) -> EvalConfig {
268 EvalConfig {
269 report_dir: self.report_dir.expect("report_dir is required"),
270 cache_dir: self.cache_dir,
271 viewing: self.viewing.unwrap_or_default(),
272 metrics: self.metrics.unwrap_or_else(MetricConfig::all),
273 quality_levels: self
274 .quality_levels
275 .unwrap_or_else(|| vec![50.0, 60.0, 70.0, 80.0, 85.0, 90.0, 95.0]),
276 }
277 }
278}
279
280struct CodecEntry {
282 id: String,
283 version: String,
284 encode: EncodeFn,
285 decode: Option<DecodeFn>,
286}
287
288pub struct EvalSession {
310 config: EvalConfig,
311 codecs: Vec<CodecEntry>,
312}
313
314impl EvalSession {
315 #[must_use]
317 pub fn new(config: EvalConfig) -> Self {
318 Self {
319 config,
320 codecs: Vec::new(),
321 }
322 }
323
324 pub fn add_codec(&mut self, id: &str, version: &str, encode: EncodeFn) -> &mut Self {
326 self.codecs.push(CodecEntry {
327 id: id.to_string(),
328 version: version.to_string(),
329 encode,
330 decode: None,
331 });
332 self
333 }
334
335 pub fn add_codec_with_decode(
337 &mut self,
338 id: &str,
339 version: &str,
340 encode: EncodeFn,
341 decode: DecodeFn,
342 ) -> &mut Self {
343 self.codecs.push(CodecEntry {
344 id: id.to_string(),
345 version: version.to_string(),
346 encode,
347 decode: Some(decode),
348 });
349 self
350 }
351
352 #[must_use]
354 pub fn codec_count(&self) -> usize {
355 self.codecs.len()
356 }
357
358 pub fn evaluate_image(&self, name: &str, image: ImageData) -> Result<ImageReport> {
369 let width = image.width() as u32;
370 let height = image.height() as u32;
371 let mut report = ImageReport::new(name.to_string(), width, height);
372
373 let reference_rgb = image.to_rgb8_vec();
374
375 for codec in &self.codecs {
376 for &quality in &self.config.quality_levels {
377 let request = EncodeRequest::new(quality);
378
379 let start = Instant::now();
381 let encoded = (codec.encode)(&image, &request)?;
382 let encode_time = start.elapsed();
383
384 let metrics = if let Some(ref decode) = codec.decode {
386 let start = Instant::now();
388 let decoded = decode(&encoded)?;
389 let decode_time = start.elapsed();
390
391 let decoded_rgb = decoded.to_rgb8_srgb()?;
395 let metrics =
396 self.calculate_metrics(&reference_rgb, &decoded_rgb, width, height)?;
397
398 report.results.push(CodecResult {
399 codec_id: codec.id.clone(),
400 codec_version: codec.version.clone(),
401 quality,
402 file_size: encoded.len(),
403 bits_per_pixel: (encoded.len() * 8) as f64 / (width as f64 * height as f64),
404 encode_time,
405 decode_time: Some(decode_time),
406 metrics: metrics.clone(),
407 perception: metrics.perception_level(),
408 cached_path: None,
409 codec_params: request.params,
410 });
411 continue;
412 } else {
413 MetricResult::default()
415 };
416
417 report.results.push(CodecResult {
418 codec_id: codec.id.clone(),
419 codec_version: codec.version.clone(),
420 quality,
421 file_size: encoded.len(),
422 bits_per_pixel: (encoded.len() * 8) as f64 / (width as f64 * height as f64),
423 encode_time,
424 decode_time: None,
425 metrics,
426 perception: None,
427 cached_path: None,
428 codec_params: request.params,
429 });
430 }
431 }
432
433 Ok(report)
434 }
435
436 fn calculate_metrics(
438 &self,
439 reference: &[u8],
440 test: &[u8],
441 width: u32,
442 height: u32,
443 ) -> Result<MetricResult> {
444 let mut result = MetricResult::default();
445
446 let reference_for_metrics: std::borrow::Cow<'_, [u8]> = if self.config.metrics.xyb_roundtrip
448 {
449 std::borrow::Cow::Owned(crate::metrics::xyb_roundtrip(
450 reference,
451 width as usize,
452 height as usize,
453 ))
454 } else {
455 std::borrow::Cow::Borrowed(reference)
456 };
457
458 if self.config.metrics.psnr {
459 result.psnr = Some(calculate_psnr(
460 &reference_for_metrics,
461 test,
462 width as usize,
463 height as usize,
464 ));
465 }
466
467 if self.config.metrics.dssim {
468 let ref_img =
469 rgb8_to_dssim_image(&reference_for_metrics, width as usize, height as usize);
470 let test_img = rgb8_to_dssim_image(test, width as usize, height as usize);
471 result.dssim = Some(crate::metrics::dssim::calculate_dssim(
472 &ref_img,
473 &test_img,
474 &self.config.viewing,
475 )?);
476 }
477
478 if self.config.metrics.ssimulacra2 {
479 result.ssimulacra2 = Some(crate::metrics::ssimulacra2::calculate_ssimulacra2(
480 &reference_for_metrics,
481 test,
482 width as usize,
483 height as usize,
484 )?);
485 }
486
487 if self.config.metrics.butteraugli {
488 result.butteraugli = Some(crate::metrics::butteraugli::calculate_butteraugli(
489 &reference_for_metrics,
490 test,
491 width as usize,
492 height as usize,
493 )?);
494 }
495
496 Ok(result)
497 }
498
499 pub fn write_image_report(&self, report: &ImageReport) -> Result<()> {
501 std::fs::create_dir_all(&self.config.report_dir)?;
502
503 let json_path = self.config.report_dir.join(format!("{}.json", report.name));
504 let json = serde_json::to_string_pretty(report)?;
505 std::fs::write(json_path, json)?;
506
507 Ok(())
508 }
509
510 pub fn write_corpus_report(&self, report: &CorpusReport) -> Result<()> {
512 std::fs::create_dir_all(&self.config.report_dir)?;
513
514 let json_path = self.config.report_dir.join(format!("{}.json", report.name));
515 let json = serde_json::to_string_pretty(report)?;
516 std::fs::write(json_path, json)?;
517
518 let csv_path = self.config.report_dir.join(format!("{}.csv", report.name));
520 self.write_csv_summary(report, &csv_path)?;
521
522 Ok(())
523 }
524
525 fn write_csv_summary(&self, report: &CorpusReport, path: &Path) -> Result<()> {
527 let mut wtr = csv::Writer::from_path(path)?;
528
529 wtr.write_record([
531 "image",
532 "codec",
533 "version",
534 "quality",
535 "file_size",
536 "bpp",
537 "encode_ms",
538 "decode_ms",
539 "dssim",
540 "ssimulacra2",
541 "butteraugli",
542 "psnr",
543 "perception",
544 ])?;
545
546 for img in &report.images {
547 for result in &img.results {
548 wtr.write_record([
549 &img.name,
550 &result.codec_id,
551 &result.codec_version,
552 &result.quality.to_string(),
553 &result.file_size.to_string(),
554 &format!("{:.4}", result.bits_per_pixel),
555 &result.encode_time.as_millis().to_string(),
556 &result
557 .decode_time
558 .map_or(String::new(), |d| d.as_millis().to_string()),
559 &result
560 .metrics
561 .dssim
562 .map_or(String::new(), |d| format!("{:.6}", d)),
563 &result
564 .metrics
565 .ssimulacra2
566 .map_or(String::new(), |s| format!("{:.2}", s)),
567 &result
568 .metrics
569 .butteraugli
570 .map_or(String::new(), |b| format!("{:.4}", b)),
571 &result
572 .metrics
573 .psnr
574 .map_or(String::new(), |p| format!("{:.2}", p)),
575 &result
576 .perception
577 .map_or(String::new(), |p| p.code().to_string()),
578 ])?;
579 }
580 }
581
582 wtr.flush()?;
583 Ok(())
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 fn create_test_image(width: usize, height: usize) -> ImageData {
592 let data: Vec<u8> = (0..width * height * 3).map(|i| (i % 256) as u8).collect();
593 ImageData::RgbSlice {
594 data,
595 width,
596 height,
597 }
598 }
599
600 #[test]
601 fn test_image_data_dimensions() {
602 let img = create_test_image(100, 50);
603 assert_eq!(img.width(), 100);
604 assert_eq!(img.height(), 50);
605 }
606
607 #[test]
608 fn test_encode_request() {
609 let req = EncodeRequest::new(80.0).with_param("subsampling", "4:2:0");
610 assert!((req.quality - 80.0).abs() < f64::EPSILON);
611 assert_eq!(req.params.get("subsampling"), Some(&"4:2:0".to_string()));
612 }
613
614 #[test]
615 fn test_eval_config_builder() {
616 let config = EvalConfig::builder()
617 .report_dir("/tmp/reports")
618 .cache_dir("/tmp/cache")
619 .viewing(ViewingCondition::laptop())
620 .quality_levels(vec![50.0, 75.0, 90.0])
621 .build();
622
623 assert_eq!(config.report_dir, PathBuf::from("/tmp/reports"));
624 assert_eq!(config.cache_dir, Some(PathBuf::from("/tmp/cache")));
625 assert!((config.viewing.acuity_ppd - 60.0).abs() < f64::EPSILON);
626 assert_eq!(config.quality_levels.len(), 3);
627 }
628
629 #[test]
630 fn test_session_add_codec() {
631 let config = EvalConfig::builder().report_dir("/tmp/test").build();
632
633 let mut session = EvalSession::new(config);
634 session.add_codec("test", "1.0", Box::new(|_, _| Ok(vec![0u8; 100])));
635
636 assert_eq!(session.codec_count(), 1);
637 }
638}