1use serde::{Deserialize, Serialize};
44use std::collections::BTreeMap;
45use std::fmt::Write as _;
46
47#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
57pub struct FixedFrame {
58 pub bpp_max: f64,
60 pub s2_max: f64,
62 pub ba_max: f64,
64 pub aspect: f64,
67}
68
69impl FixedFrame {
70 pub const WEB: Self = Self {
76 bpp_max: 4.0,
77 s2_max: 100.0,
78 ba_max: 15.0,
79 aspect: (1.0 - 0.7274 / 4.0) / (65.10 / 100.0),
80 };
81
82 #[must_use]
89 pub fn s2_angle(&self, bpp: f64, s2: f64) -> f64 {
90 let bpp_norm = bpp / self.bpp_max;
91 let s2_norm = s2 / self.s2_max;
92 (s2_norm * self.aspect).atan2(1.0 - bpp_norm).to_degrees()
93 }
94
95 #[must_use]
101 pub fn ba_angle(&self, bpp: f64, ba: f64) -> f64 {
102 let bpp_norm = bpp / self.bpp_max;
103 let ba_norm = 1.0 - ba / self.ba_max;
104 (ba_norm * self.aspect).atan2(1.0 - bpp_norm).to_degrees()
105 }
106
107 #[must_use]
109 pub fn position(&self, bpp: f64, s2: f64, ba: f64) -> RDPosition {
110 RDPosition {
111 theta_s2: self.s2_angle(bpp, s2),
112 theta_ba: self.ba_angle(bpp, ba),
113 bpp,
114 ssimulacra2: s2,
115 butteraugli: ba,
116 }
117 }
118}
119
120impl Default for FixedFrame {
121 fn default() -> Self {
122 Self::WEB
123 }
124}
125
126#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
132pub struct AxisRange {
133 pub min: f64,
134 pub max: f64,
135}
136
137impl AxisRange {
138 #[must_use]
139 pub fn new(min: f64, max: f64) -> Self {
140 debug_assert!(max > min, "AxisRange max must exceed min");
141 Self { min, max }
142 }
143
144 #[must_use]
145 pub fn normalize(&self, value: f64) -> f64 {
146 (value - self.min) / (self.max - self.min)
147 }
148
149 #[must_use]
150 pub fn denormalize(&self, norm: f64) -> f64 {
151 norm * (self.max - self.min) + self.min
152 }
153
154 #[must_use]
155 pub fn span(&self) -> f64 {
156 self.max - self.min
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162pub enum QualityDirection {
163 HigherIsBetter,
164 LowerIsBetter,
165}
166
167#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
169pub struct NormalizationContext {
170 pub bpp_range: AxisRange,
171 pub quality_range: AxisRange,
172 pub direction: QualityDirection,
173}
174
175impl NormalizationContext {
176 #[must_use]
177 pub fn normalize_bpp(&self, bpp: f64) -> f64 {
178 self.bpp_range.normalize(bpp)
179 }
180
181 #[must_use]
182 pub fn normalize_quality(&self, raw_quality: f64) -> f64 {
183 match self.direction {
184 QualityDirection::HigherIsBetter => self.quality_range.normalize(raw_quality),
185 QualityDirection::LowerIsBetter => 1.0 - self.quality_range.normalize(raw_quality),
186 }
187 }
188}
189
190#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
199pub struct RDKnee {
200 pub bpp: f64,
202
203 pub quality: f64,
205
206 pub fixed_angle: f64,
209
210 pub norm: NormalizationContext,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct RDCalibration {
221 pub frame: FixedFrame,
223
224 pub ssimulacra2: RDKnee,
226
227 pub butteraugli: RDKnee,
229
230 pub corpus: String,
232
233 pub codec: String,
235
236 pub image_count: usize,
238
239 pub computed_at: String,
241}
242
243impl RDCalibration {
244 #[must_use]
246 pub fn disagreement_range(&self) -> (f64, f64) {
247 let a = self.ssimulacra2.bpp;
248 let b = self.butteraugli.bpp;
249 (a.min(b), a.max(b))
250 }
251
252 #[must_use]
254 pub fn position(&self, bpp: f64, s2: f64, ba: f64) -> RDPosition {
255 self.frame.position(bpp, s2, ba)
256 }
257}
258
259#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
273pub struct RDPosition {
274 pub theta_s2: f64,
276
277 pub theta_ba: f64,
279
280 pub bpp: f64,
282
283 pub ssimulacra2: f64,
285
286 pub butteraugli: f64,
288}
289
290impl RDPosition {
291 #[must_use]
293 pub fn in_disagreement_zone(&self, cal: &RDCalibration) -> bool {
294 let (lo, hi) = cal.disagreement_range();
295 self.bpp >= lo && self.bpp <= hi
296 }
297
298 #[must_use]
300 pub fn bin(&self, scheme: &BinScheme) -> AngleBin {
301 scheme.bin_for(self.theta_s2)
302 }
303
304 #[must_use]
306 pub fn dual_bin(&self, scheme: &BinScheme) -> DualAngleBin {
307 DualAngleBin {
308 s2: scheme.bin_for(self.theta_s2),
309 ba: scheme.bin_for(self.theta_ba),
310 }
311 }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct BinScheme {
321 pub start: f64,
323 pub width: f64,
325 pub count: usize,
327}
328
329impl BinScheme {
330 #[must_use]
332 pub fn range(lo: f64, hi: f64, count: usize) -> Self {
333 let width = (hi - lo) / count as f64;
334 Self {
335 start: lo + width / 2.0,
336 width,
337 count,
338 }
339 }
340
341 #[must_use]
343 pub fn default_18() -> Self {
344 Self::range(0.0, 90.0, 18)
345 }
346
347 #[must_use]
349 pub fn fine_36() -> Self {
350 Self::range(0.0, 90.0, 36)
351 }
352
353 #[must_use]
355 pub fn bin_for(&self, angle_deg: f64) -> AngleBin {
356 let first_edge = self.start - self.width / 2.0;
357 let offset = angle_deg - first_edge;
358 let idx = (offset / self.width).floor();
359 let idx = (idx.clamp(0.0, (self.count - 1) as f64)) as usize;
360 let center = self.start + idx as f64 * self.width;
361 AngleBin {
362 index: idx,
363 center,
364 width: self.width,
365 }
366 }
367
368 pub fn bins(&self) -> impl Iterator<Item = AngleBin> + '_ {
370 (0..self.count).map(move |i| {
371 let center = self.start + i as f64 * self.width;
372 AngleBin {
373 index: i,
374 center,
375 width: self.width,
376 }
377 })
378 }
379}
380
381#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
383pub struct AngleBin {
384 pub index: usize,
385 pub center: f64,
386 pub width: f64,
387}
388
389impl AngleBin {
390 #[must_use]
391 pub fn lo(&self) -> f64 {
392 self.center - self.width / 2.0
393 }
394
395 #[must_use]
396 pub fn hi(&self) -> f64 {
397 self.center + self.width / 2.0
398 }
399
400 #[must_use]
401 pub fn contains(&self, angle_deg: f64) -> bool {
402 angle_deg >= self.lo() && angle_deg < self.hi()
403 }
404}
405
406#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
408pub struct DualAngleBin {
409 pub s2: AngleBin,
410 pub ba: AngleBin,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
419#[serde(untagged)]
420pub enum ParamValue {
421 Int(i64),
422 Float(f64),
423 Bool(bool),
424 Text(String),
425}
426
427impl std::fmt::Display for ParamValue {
428 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429 match self {
430 Self::Int(v) => write!(f, "{v}"),
431 Self::Float(v) => write!(f, "{v}"),
432 Self::Bool(v) => write!(f, "{v}"),
433 Self::Text(v) => write!(f, "{v}"),
434 }
435 }
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct CodecConfig {
441 pub codec: String,
442 pub version: String,
443 pub params: BTreeMap<String, ParamValue>,
444}
445
446impl CodecConfig {
447 #[must_use]
448 pub fn new(codec: impl Into<String>, version: impl Into<String>) -> Self {
449 Self {
450 codec: codec.into(),
451 version: version.into(),
452 params: BTreeMap::new(),
453 }
454 }
455
456 #[must_use]
457 pub fn with_param(mut self, key: impl Into<String>, value: ParamValue) -> Self {
458 self.params.insert(key.into(), value);
459 self
460 }
461
462 #[must_use]
463 pub fn fingerprint(&self) -> String {
464 let params: Vec<String> = self
465 .params
466 .iter()
467 .map(|(k, v)| format!("{k}={v}"))
468 .collect();
469 format!("{}@{} [{}]", self.codec, self.version, params.join(", "))
470 }
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct ConfiguredRDPoint {
480 pub position: RDPosition,
481 pub config: CodecConfig,
482 pub image: Option<String>,
483 pub encode_time_ms: Option<f64>,
484 pub decode_time_ms: Option<f64>,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct ConfiguredParetoFront {
490 pub calibration: RDCalibration,
491 pub scheme: BinScheme,
492 pub points: Vec<ConfiguredRDPoint>,
493}
494
495impl ConfiguredParetoFront {
496 #[must_use]
498 pub fn compute(
499 points: Vec<ConfiguredRDPoint>,
500 calibration: RDCalibration,
501 scheme: BinScheme,
502 ) -> Self {
503 let mut front: Vec<ConfiguredRDPoint> = Vec::new();
504
505 for point in &points {
506 let dominated = front.iter().any(|p| {
507 p.position.bpp <= point.position.bpp
508 && p.position.ssimulacra2 >= point.position.ssimulacra2
509 && (p.position.bpp < point.position.bpp
510 || p.position.ssimulacra2 > point.position.ssimulacra2)
511 });
512
513 if !dominated {
514 front.retain(|p| {
515 !(point.position.bpp <= p.position.bpp
516 && point.position.ssimulacra2 >= p.position.ssimulacra2
517 && (point.position.bpp < p.position.bpp
518 || point.position.ssimulacra2 > p.position.ssimulacra2))
519 });
520 front.push(point.clone());
521 }
522 }
523
524 front.sort_by(|a, b| {
525 a.position
526 .bpp
527 .partial_cmp(&b.position.bpp)
528 .unwrap_or(std::cmp::Ordering::Equal)
529 });
530
531 Self {
532 calibration,
533 scheme,
534 points: front,
535 }
536 }
537
538 #[must_use]
539 pub fn best_config_for_s2(&self, min_s2: f64) -> Option<&ConfiguredRDPoint> {
540 self.points
541 .iter()
542 .filter(|p| p.position.ssimulacra2 >= min_s2)
543 .min_by(|a, b| {
544 a.position
545 .bpp
546 .partial_cmp(&b.position.bpp)
547 .unwrap_or(std::cmp::Ordering::Equal)
548 })
549 }
550
551 #[must_use]
552 pub fn best_config_for_ba(&self, max_ba: f64) -> Option<&ConfiguredRDPoint> {
553 self.points
554 .iter()
555 .filter(|p| p.position.butteraugli <= max_ba)
556 .min_by(|a, b| {
557 a.position
558 .bpp
559 .partial_cmp(&b.position.bpp)
560 .unwrap_or(std::cmp::Ordering::Equal)
561 })
562 }
563
564 #[must_use]
565 pub fn best_config_for_bpp(&self, max_bpp: f64) -> Option<&ConfiguredRDPoint> {
566 self.points
567 .iter()
568 .filter(|p| p.position.bpp <= max_bpp)
569 .max_by(|a, b| {
570 a.position
571 .ssimulacra2
572 .partial_cmp(&b.position.ssimulacra2)
573 .unwrap_or(std::cmp::Ordering::Equal)
574 })
575 }
576
577 #[must_use]
578 pub fn in_bin(&self, bin: &AngleBin) -> Vec<&ConfiguredRDPoint> {
579 self.points
580 .iter()
581 .filter(|p| bin.contains(p.position.theta_s2))
582 .collect()
583 }
584
585 #[must_use]
586 pub fn coverage(&self) -> Vec<(AngleBin, usize)> {
587 self.scheme
588 .bins()
589 .map(|bin| {
590 let count = self
591 .points
592 .iter()
593 .filter(|p| bin.contains(p.position.theta_s2))
594 .count();
595 (bin, count)
596 })
597 .collect()
598 }
599
600 #[must_use]
601 pub fn empty_bins(&self) -> Vec<AngleBin> {
602 self.coverage()
603 .into_iter()
604 .filter(|(_, count)| *count == 0)
605 .map(|(bin, _)| bin)
606 .collect()
607 }
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct EncodeResult {
617 pub bpp: f64,
618 pub ssimulacra2: f64,
619 pub butteraugli: f64,
620 pub image: String,
621 pub config: CodecConfig,
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize)]
626pub struct CorpusAggregate {
627 pub corpus: String,
628 pub codec: String,
629 pub curve: Vec<(f64, f64, f64)>,
631 pub image_count: usize,
632}
633
634impl CorpusAggregate {
635 #[must_use]
637 pub fn ssimulacra2_knee(&self, frame: &FixedFrame) -> Option<RDKnee> {
638 self.find_knee_for(
639 QualityDirection::HigherIsBetter,
640 |(_b, s, _ba)| *s,
641 |bpp, quality| frame.s2_angle(bpp, quality),
642 )
643 }
644
645 #[must_use]
647 pub fn butteraugli_knee(&self, frame: &FixedFrame) -> Option<RDKnee> {
648 self.find_knee_for(
649 QualityDirection::LowerIsBetter,
650 |(_b, _s, ba)| *ba,
651 |bpp, quality| frame.ba_angle(bpp, quality),
652 )
653 }
654
655 #[must_use]
657 pub fn calibrate(&self, frame: &FixedFrame) -> Option<RDCalibration> {
658 let s2_knee = self.ssimulacra2_knee(frame)?;
659 let ba_knee = self.butteraugli_knee(frame)?;
660
661 Some(RDCalibration {
662 frame: *frame,
663 ssimulacra2: s2_knee,
664 butteraugli: ba_knee,
665 corpus: self.corpus.clone(),
666 codec: self.codec.clone(),
667 image_count: self.image_count,
668 computed_at: String::new(),
669 })
670 }
671
672 fn find_knee_for(
673 &self,
674 direction: QualityDirection,
675 extract: impl Fn(&(f64, f64, f64)) -> f64,
676 compute_fixed_angle: impl Fn(f64, f64) -> f64,
677 ) -> Option<RDKnee> {
678 if self.curve.len() < 3 {
679 return None;
680 }
681
682 let bpp_vals: Vec<f64> = self.curve.iter().map(|(b, _, _)| *b).collect();
683 let q_vals: Vec<f64> = self.curve.iter().map(&extract).collect();
684
685 let bpp_range = AxisRange::new(
686 *bpp_vals.iter().min_by(|a, b| a.partial_cmp(b).unwrap())?,
687 *bpp_vals.iter().max_by(|a, b| a.partial_cmp(b).unwrap())?,
688 );
689 let quality_range = AxisRange::new(
690 *q_vals.iter().min_by(|a, b| a.partial_cmp(b).unwrap())?,
691 *q_vals.iter().max_by(|a, b| a.partial_cmp(b).unwrap())?,
692 );
693
694 let norm = NormalizationContext {
695 bpp_range,
696 quality_range,
697 direction,
698 };
699
700 find_knee(&self.curve, &norm, &extract, &compute_fixed_angle)
701 }
702}
703
704fn find_knee(
707 curve: &[(f64, f64, f64)],
708 norm: &NormalizationContext,
709 extract_quality: &impl Fn(&(f64, f64, f64)) -> f64,
710 compute_fixed_angle: &impl Fn(f64, f64) -> f64,
711) -> Option<RDKnee> {
712 if curve.len() < 2 {
713 return None;
714 }
715
716 let mut slopes: Vec<(usize, f64)> = Vec::new();
717 for i in 0..curve.len() - 1 {
718 let bpp0 = norm.normalize_bpp(curve[i].0);
719 let bpp1 = norm.normalize_bpp(curve[i + 1].0);
720 let q0 = norm.normalize_quality(extract_quality(&curve[i]));
721 let q1 = norm.normalize_quality(extract_quality(&curve[i + 1]));
722
723 let d_bpp = bpp1 - bpp0;
724 if d_bpp.abs() < 1e-12 {
725 continue;
726 }
727
728 slopes.push((i, (q1 - q0) / d_bpp));
729 }
730
731 if slopes.is_empty() {
732 return None;
733 }
734
735 let crossing_idx = slopes
736 .iter()
737 .position(|(_, slope)| *slope <= 1.0)
738 .unwrap_or(slopes.len() / 2);
739
740 let (seg_idx, _) = slopes[crossing_idx];
741 let bpp = (curve[seg_idx].0 + curve[seg_idx + 1].0) / 2.0;
742 let quality = (extract_quality(&curve[seg_idx]) + extract_quality(&curve[seg_idx + 1])) / 2.0;
743
744 Some(RDKnee {
745 bpp,
746 quality,
747 fixed_angle: compute_fixed_angle(bpp, quality),
748 norm: *norm,
749 })
750}
751
752#[must_use]
761pub fn plot_rd_svg(curve: &[(f64, f64, f64)], calibration: &RDCalibration, title: &str) -> String {
762 let frame = &calibration.frame;
763 let margin = 60.0_f64;
764 let plot_w = 600.0_f64;
765 let plot_h = 400.0_f64;
766 let total_w = plot_w + margin * 2.0;
767 let total_h = plot_h + margin * 2.0;
768
769 let x_of = |bpp: f64| -> f64 { margin + (bpp / frame.bpp_max) * plot_w };
773 let y_of = |s2: f64| -> f64 { margin + plot_h - (s2.max(0.0) / frame.s2_max) * plot_h };
774
775 let cx = x_of(frame.bpp_max);
777 let cy = y_of(0.0);
778
779 let mut svg = String::with_capacity(8192);
780
781 let _ = write!(
783 svg,
784 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {total_w} {total_h}" font-family="monospace" font-size="11">"##
785 );
786
787 let _ = write!(
789 svg,
790 r##"<rect width="{total_w}" height="{total_h}" fill="#1a1a2e"/>"##
791 );
792
793 let _ = write!(
795 svg,
796 r##"<rect x="{margin}" y="{margin}" width="{plot_w}" height="{plot_h}" fill="#16213e" stroke="#333" stroke-width="1"/>"##
797 );
798
799 for deg in (0..=90).step_by(15) {
801 let rad = (deg as f64).to_radians();
802 let q_norm = rad.sin();
803 let r_norm = rad.cos(); let scale = if r_norm.abs() > 1e-6 {
808 (1.0 / r_norm).min(if q_norm.abs() > 1e-6 {
809 1.0 / q_norm
810 } else {
811 f64::MAX
812 })
813 } else if q_norm.abs() > 1e-6 {
814 1.0 / q_norm
815 } else {
816 1.0
817 };
818
819 let bpp_far = frame.bpp_max * (1.0 - r_norm * scale).clamp(0.0, 1.0);
820 let s2_far = (frame.s2_max * q_norm * scale).clamp(0.0, frame.s2_max);
821
822 let opacity = if deg == 45 { "0.4" } else { "0.15" };
823 let color = if deg == 45 { "#ffd700" } else { "#888" };
824
825 let _ = write!(
826 svg,
827 r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{color}" stroke-width="1" stroke-dasharray="4,4" opacity="{opacity}"/>"##,
828 cx,
829 cy,
830 x_of(bpp_far),
831 y_of(s2_far)
832 );
833
834 let label_dist = 35.0;
836 let lx = cx - label_dist * r_norm;
837 let ly = cy - label_dist * q_norm;
838 let _ = write!(
839 svg,
840 r##"<text x="{lx:.0}" y="{ly:.0}" fill="#666" text-anchor="middle" font-size="9">{deg}°</text>"##
841 );
842 }
843
844 for bpp_tick in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5] {
846 let x = x_of(bpp_tick);
847 let _ = write!(
848 svg,
849 r##"<line x1="{x}" y1="{margin}" x2="{x}" y2="{}" stroke="#333" stroke-width="0.5"/>"##,
850 margin + plot_h
851 );
852 let _ = write!(
853 svg,
854 r##"<text x="{x}" y="{}" fill="#888" text-anchor="middle">{bpp_tick}</text>"##,
855 margin + plot_h + 16.0
856 );
857 }
858 for s2_tick in [0.0, 20.0, 40.0, 60.0, 80.0, 100.0] {
859 let y = y_of(s2_tick);
860 let _ = write!(
861 svg,
862 r##"<line x1="{margin}" y1="{y}" x2="{}" y2="{y}" stroke="#333" stroke-width="0.5"/>"##,
863 margin + plot_w
864 );
865 let _ = write!(
866 svg,
867 r##"<text x="{}" y="{}" fill="#888" text-anchor="end">{s2_tick:.0}</text>"##,
868 margin - 6.0,
869 y + 4.0
870 );
871 }
872
873 if curve.len() >= 2 {
875 let mut path = String::from("M");
876 for (i, (bpp, s2, _ba)) in curve.iter().enumerate() {
877 let sep = if i == 0 { "" } else { " L" };
878 let _ = write!(path, "{sep}{:.1},{:.1}", x_of(*bpp), y_of(*s2));
879 }
880 let _ = write!(
881 svg,
882 r##"<path d="{path}" fill="none" stroke="#e74c3c" stroke-width="2.5" stroke-linejoin="round"/>"##
883 );
884
885 for (bpp, s2, _ba) in curve {
887 let _ = write!(
888 svg,
889 r##"<circle cx="{:.1}" cy="{:.1}" r="3" fill="#e74c3c" opacity="0.8"/>"##,
890 x_of(*bpp),
891 y_of(*s2)
892 );
893 }
894 }
895
896 let s2_knee = &calibration.ssimulacra2;
898 let kx = x_of(s2_knee.bpp);
899 let ky = y_of(s2_knee.quality);
900 let _ = write!(
901 svg,
902 r##"<circle cx="{kx:.1}" cy="{ky:.1}" r="7" fill="none" stroke="#ffd700" stroke-width="2.5"/>"##
903 );
904 let _ = write!(
905 svg,
906 r##"<text x="{:.0}" y="{:.0}" fill="#ffd700" font-size="10">s2 knee {:.1}° ({:.2} bpp, s2={:.1})</text>"##,
907 kx + 12.0,
908 ky - 4.0,
909 s2_knee.fixed_angle,
910 s2_knee.bpp,
911 s2_knee.quality
912 );
913
914 let ba_knee = &calibration.butteraugli;
915 let s2_at_ba_knee = interpolate_curve_s2(curve, ba_knee.bpp).unwrap_or(50.0);
917 let bkx = x_of(ba_knee.bpp);
918 let bky = y_of(s2_at_ba_knee);
919 let _ = write!(
920 svg,
921 r##"<circle cx="{bkx:.1}" cy="{bky:.1}" r="7" fill="none" stroke="#3498db" stroke-width="2.5"/>"##
922 );
923 let _ = write!(
924 svg,
925 r##"<text x="{:.0}" y="{:.0}" fill="#3498db" font-size="10">ba knee {:.1}° ({:.2} bpp, ba={:.2})</text>"##,
926 bkx + 12.0,
927 bky + 14.0,
928 ba_knee.fixed_angle,
929 ba_knee.bpp,
930 ba_knee.quality
931 );
932
933 let (dis_lo, dis_hi) = calibration.disagreement_range();
935 let _ = write!(
936 svg,
937 r##"<rect x="{:.1}" y="{margin}" width="{:.1}" height="{plot_h}" fill="#ffd700" opacity="0.06"/>"##,
938 x_of(dis_lo),
939 x_of(dis_hi) - x_of(dis_lo)
940 );
941
942 let _ = write!(
944 svg,
945 r##"<text x="{:.0}" y="{}" fill="#ccc" text-anchor="middle" font-size="12">bpp</text>"##,
946 margin + plot_w / 2.0,
947 margin + plot_h + 35.0
948 );
949 let _ = write!(
950 svg,
951 r##"<text x="{}" y="{:.0}" fill="#ccc" text-anchor="middle" font-size="12" transform="rotate(-90,{},{:.0})">SSIMULACRA2</text>"##,
952 margin - 40.0,
953 margin + plot_h / 2.0,
954 margin - 40.0,
955 margin + plot_h / 2.0
956 );
957
958 let _ = write!(
960 svg,
961 r##"<text x="{:.0}" y="{}" fill="#eee" text-anchor="middle" font-size="14" font-weight="bold">{title}</text>"##,
962 margin + plot_w / 2.0,
963 margin - 15.0
964 );
965
966 let _ = write!(
968 svg,
969 r##"<circle cx="{cx:.0}" cy="{cy:.0}" r="4" fill="#ff6b6b"/>"##
970 );
971 let _ = write!(
972 svg,
973 r##"<text x="{:.0}" y="{:.0}" fill="#ff6b6b" font-size="9" text-anchor="end">origin</text>"##,
974 cx - 8.0,
975 cy + 4.0
976 );
977
978 svg.push_str("</svg>");
979 svg
980}
981
982fn interpolate_curve_s2(curve: &[(f64, f64, f64)], target_bpp: f64) -> Option<f64> {
984 if curve.len() < 2 {
985 return None;
986 }
987 for w in curve.windows(2) {
988 let (b0, s0, _) = w[0];
989 let (b1, s1, _) = w[1];
990 if target_bpp >= b0 && target_bpp <= b1 && (b1 - b0).abs() > 1e-12 {
991 let t = (target_bpp - b0) / (b1 - b0);
992 return Some(s0 + t * (s1 - s0));
993 }
994 }
995 None
996}
997
998pub mod defaults {
1008 use super::{
1009 AxisRange, FixedFrame, NormalizationContext, QualityDirection, RDCalibration, RDKnee,
1010 };
1011
1012 #[must_use]
1018 pub fn mozjpeg_cid22() -> RDCalibration {
1019 let frame = FixedFrame::WEB;
1020 RDCalibration {
1021 frame,
1022 ssimulacra2: RDKnee {
1023 bpp: 0.7274,
1024 quality: 65.10,
1025 fixed_angle: frame.s2_angle(0.7274, 65.10),
1026 norm: NormalizationContext {
1027 bpp_range: AxisRange::new(0.1760, 3.6274),
1028 quality_range: AxisRange::new(-8.48, 87.99),
1029 direction: QualityDirection::HigherIsBetter,
1030 },
1031 },
1032 butteraugli: RDKnee {
1033 bpp: 0.7048,
1034 quality: 4.378,
1035 fixed_angle: frame.ba_angle(0.7048, 4.378),
1036 norm: NormalizationContext {
1037 bpp_range: AxisRange::new(0.1760, 3.6274),
1038 quality_range: AxisRange::new(1.854, 11.663),
1039 direction: QualityDirection::LowerIsBetter,
1040 },
1041 },
1042 corpus: "CID22-training".into(),
1043 codec: "mozjpeg-420-prog".into(),
1044 image_count: 209,
1045 computed_at: "2026-02-03T22:56:01Z".into(),
1046 }
1047 }
1048
1049 #[must_use]
1055 pub fn mozjpeg_clic2025() -> RDCalibration {
1056 let frame = FixedFrame::WEB;
1057 RDCalibration {
1058 frame,
1059 ssimulacra2: RDKnee {
1060 bpp: 0.4623,
1061 quality: 58.95,
1062 fixed_angle: frame.s2_angle(0.4623, 58.95),
1063 norm: NormalizationContext {
1064 bpp_range: AxisRange::new(0.1194, 3.0694),
1065 quality_range: AxisRange::new(-16.94, 87.63),
1066 direction: QualityDirection::HigherIsBetter,
1067 },
1068 },
1069 butteraugli: RDKnee {
1070 bpp: 0.3948,
1071 quality: 5.192,
1072 fixed_angle: frame.ba_angle(0.3948, 5.192),
1073 norm: NormalizationContext {
1074 bpp_range: AxisRange::new(0.1194, 3.0694),
1075 quality_range: AxisRange::new(1.895, 13.264),
1076 direction: QualityDirection::LowerIsBetter,
1077 },
1078 },
1079 corpus: "CLIC2025-training".into(),
1080 codec: "mozjpeg-420-prog".into(),
1081 image_count: 32,
1082 computed_at: "2026-02-03T23:09:01Z".into(),
1083 }
1084 }
1085}
1086
1087#[cfg(test)]
1092mod tests {
1093 use super::{
1094 AngleBin, AxisRange, BinScheme, CodecConfig, ConfiguredParetoFront, ConfiguredRDPoint,
1095 CorpusAggregate, FixedFrame, NormalizationContext, ParamValue, QualityDirection, defaults,
1096 };
1097
1098 fn make_test_curve() -> Vec<(f64, f64, f64)> {
1099 vec![
1100 (0.10, 25.0, 8.0),
1101 (0.20, 40.0, 5.5),
1102 (0.30, 52.0, 3.8),
1103 (0.50, 62.0, 2.5),
1104 (0.70, 70.0, 1.8),
1105 (1.00, 78.0, 1.2),
1106 (1.50, 84.0, 0.8),
1107 (2.00, 88.0, 0.6),
1108 (3.00, 92.0, 0.4),
1109 ]
1110 }
1111
1112 #[test]
1113 fn test_fixed_frame_s2_corner() {
1114 let f = FixedFrame::WEB;
1115 assert!(f.s2_angle(4.0, 0.0).abs() < 0.01);
1117 let ideal = f.s2_angle(0.0, 100.0);
1119 assert!(ideal > 50.0 && ideal < 53.0, "ideal angle: {ideal}");
1120 assert!((f.s2_angle(0.7274, 65.10) - 45.0).abs() < 0.1);
1122 assert!((f.s2_angle(4.0, 100.0) - 90.0).abs() < 0.01);
1124 assert!(f.s2_angle(2.0, -10.0) < 0.0);
1126 assert!(f.s2_angle(5.0, 50.0) > 90.0);
1128 }
1129
1130 #[test]
1131 fn test_fixed_frame_ba_corner() {
1132 let f = FixedFrame::WEB;
1133 assert!(f.ba_angle(4.0, 15.0).abs() < 0.01);
1135 let ideal = f.ba_angle(0.0, 0.0);
1137 assert!(ideal > 50.0 && ideal < 53.0, "ba ideal angle: {ideal}");
1138 }
1139
1140 #[test]
1141 fn test_fixed_frame_comparable() {
1142 let f = FixedFrame::WEB;
1143 let a = f.s2_angle(0.7274, 65.10); assert!((a - 45.0).abs() < 0.1);
1147 let b = f.s2_angle(2.0, 50.0);
1152 assert!(b > 45.0, "should be above knee: {b}");
1153 }
1154
1155 #[test]
1156 fn test_axis_range_normalize() {
1157 let r = AxisRange::new(0.0, 10.0);
1158 assert!((r.normalize(5.0) - 0.5).abs() < 1e-10);
1159 }
1160
1161 #[test]
1162 fn test_axis_range_roundtrip() {
1163 let r = AxisRange::new(2.0, 8.0);
1164 let val = 5.5;
1165 assert!((r.denormalize(r.normalize(val)) - val).abs() < 1e-10);
1166 }
1167
1168 #[test]
1169 fn test_quality_direction_higher_is_better() {
1170 let ctx = NormalizationContext {
1171 bpp_range: AxisRange::new(0.0, 3.0),
1172 quality_range: AxisRange::new(20.0, 100.0),
1173 direction: QualityDirection::HigherIsBetter,
1174 };
1175 assert!((ctx.normalize_quality(100.0) - 1.0).abs() < 1e-10);
1176 assert!(ctx.normalize_quality(20.0).abs() < 1e-10);
1177 }
1178
1179 #[test]
1180 fn test_quality_direction_lower_is_better() {
1181 let ctx = NormalizationContext {
1182 bpp_range: AxisRange::new(0.0, 3.0),
1183 quality_range: AxisRange::new(0.5, 12.0),
1184 direction: QualityDirection::LowerIsBetter,
1185 };
1186 assert!((ctx.normalize_quality(0.5) - 1.0).abs() < 1e-10);
1187 assert!(ctx.normalize_quality(12.0).abs() < 1e-10);
1188 }
1189
1190 #[test]
1191 fn test_knee_detection_s2() {
1192 let curve = make_test_curve();
1193 let agg = CorpusAggregate {
1194 corpus: "test".into(),
1195 codec: "test-codec".into(),
1196 curve,
1197 image_count: 1,
1198 };
1199
1200 let knee = agg
1201 .ssimulacra2_knee(&FixedFrame::WEB)
1202 .expect("should find knee");
1203 assert!(knee.bpp > 0.2, "knee bpp too low: {}", knee.bpp);
1204 assert!(knee.bpp < 2.0, "knee bpp too high: {}", knee.bpp);
1205 assert!(knee.quality > 40.0, "knee s2 too low: {}", knee.quality);
1206 assert!(knee.quality < 90.0, "knee s2 too high: {}", knee.quality);
1207 assert!(
1209 knee.fixed_angle > 20.0,
1210 "angle too low: {}",
1211 knee.fixed_angle
1212 );
1213 assert!(
1214 knee.fixed_angle < 70.0,
1215 "angle too high: {}",
1216 knee.fixed_angle
1217 );
1218 }
1219
1220 #[test]
1221 fn test_knee_detection_ba() {
1222 let curve = make_test_curve();
1223 let agg = CorpusAggregate {
1224 corpus: "test".into(),
1225 codec: "test-codec".into(),
1226 curve,
1227 image_count: 1,
1228 };
1229
1230 let knee = agg
1231 .butteraugli_knee(&FixedFrame::WEB)
1232 .expect("should find knee");
1233 assert!(knee.bpp > 0.2);
1234 assert!(knee.bpp < 2.0);
1235 assert!(knee.fixed_angle > 20.0);
1236 assert!(knee.fixed_angle < 70.0);
1237 }
1238
1239 #[test]
1240 fn test_calibration_disagreement_range() {
1241 let curve = make_test_curve();
1242 let agg = CorpusAggregate {
1243 corpus: "test".into(),
1244 codec: "test-codec".into(),
1245 curve,
1246 image_count: 1,
1247 };
1248
1249 let cal = agg.calibrate(&FixedFrame::WEB).expect("should calibrate");
1250 let (lo, hi) = cal.disagreement_range();
1251 assert!(lo <= hi);
1252 assert!(lo > 0.0);
1253 }
1254
1255 #[test]
1256 fn test_defaults_knee_angles() {
1257 let cal = defaults::mozjpeg_cid22();
1258 assert!(
1260 (cal.ssimulacra2.fixed_angle - 45.0).abs() < 0.5,
1261 "s2 knee angle {:.1}° should be ~45°",
1262 cal.ssimulacra2.fixed_angle
1263 );
1264 assert!(
1266 cal.butteraugli.fixed_angle > 40.0 && cal.butteraugli.fixed_angle < 55.0,
1267 "ba knee angle {:.1}° outside expected 40-55° range",
1268 cal.butteraugli.fixed_angle
1269 );
1270 let diff = (cal.ssimulacra2.fixed_angle - cal.butteraugli.fixed_angle).abs();
1272 assert!(
1273 diff < 10.0,
1274 "knee angle difference {:.1}° too large (s2={:.1}°, ba={:.1}°)",
1275 diff,
1276 cal.ssimulacra2.fixed_angle,
1277 cal.butteraugli.fixed_angle
1278 );
1279 }
1280
1281 #[test]
1282 fn test_bin_scheme_range() {
1283 let scheme = BinScheme::default_18();
1284 assert_eq!(scheme.count, 18);
1285 assert!((scheme.width - 5.0).abs() < 1e-10);
1286
1287 let bins: Vec<AngleBin> = scheme.bins().collect();
1288 assert_eq!(bins.len(), 18);
1289 assert!((bins[0].center - 2.5).abs() < 1e-10);
1290 assert!((bins[17].center - 87.5).abs() < 1e-10);
1291 }
1292
1293 #[test]
1294 fn test_bin_assignment() {
1295 let scheme = BinScheme::default_18();
1296 let bin = scheme.bin_for(45.0);
1297 assert!(bin.contains(45.0));
1298 }
1299
1300 #[test]
1301 fn test_codec_config_fingerprint() {
1302 let config = CodecConfig::new("mozjpeg-rs", "0.5.0")
1303 .with_param("quality", ParamValue::Int(75))
1304 .with_param("trellis", ParamValue::Bool(true));
1305 let fp = config.fingerprint();
1306 assert!(fp.contains("mozjpeg-rs"));
1307 assert!(fp.contains("quality=75"));
1308 }
1309
1310 #[test]
1311 fn test_configured_pareto_front() {
1312 let cal = defaults::mozjpeg_cid22();
1313
1314 let points: Vec<ConfiguredRDPoint> = vec![
1315 ConfiguredRDPoint {
1316 position: cal.position(0.3, 50.0, 4.0),
1317 config: CodecConfig::new("test", "1.0").with_param("q", ParamValue::Int(30)),
1318 image: None,
1319 encode_time_ms: None,
1320 decode_time_ms: None,
1321 },
1322 ConfiguredRDPoint {
1323 position: cal.position(0.5, 65.0, 2.5),
1324 config: CodecConfig::new("test", "1.0").with_param("q", ParamValue::Int(50)),
1325 image: None,
1326 encode_time_ms: None,
1327 decode_time_ms: None,
1328 },
1329 ConfiguredRDPoint {
1330 position: cal.position(1.0, 80.0, 1.0),
1331 config: CodecConfig::new("test", "1.0").with_param("q", ParamValue::Int(80)),
1332 image: None,
1333 encode_time_ms: None,
1334 decode_time_ms: None,
1335 },
1336 ConfiguredRDPoint {
1337 position: cal.position(0.6, 60.0, 3.0),
1338 config: CodecConfig::new("test", "1.0").with_param("q", ParamValue::Int(45)),
1339 image: None,
1340 encode_time_ms: None,
1341 decode_time_ms: None,
1342 },
1343 ];
1344
1345 let scheme = BinScheme::default_18();
1346 let front = ConfiguredParetoFront::compute(points, cal, scheme);
1347
1348 assert_eq!(front.points.len(), 3);
1350
1351 for p in &front.points {
1353 assert!(
1354 p.position.theta_s2 > 0.0,
1355 "s2 angle: {}",
1356 p.position.theta_s2
1357 );
1358 assert!(
1359 p.position.theta_ba > 0.0,
1360 "ba angle: {}",
1361 p.position.theta_ba
1362 );
1363 }
1364
1365 let best = front.best_config_for_s2(70.0).unwrap();
1366 assert_eq!(best.config.params.get("q"), Some(&ParamValue::Int(80)));
1367
1368 let best = front.best_config_for_bpp(0.5).unwrap();
1369 assert_eq!(best.config.params.get("q"), Some(&ParamValue::Int(50)));
1370 }
1371}