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 =
743 (extract_quality(&curve[seg_idx]) + extract_quality(&curve[seg_idx + 1])) / 2.0;
744
745 Some(RDKnee {
746 bpp,
747 quality,
748 fixed_angle: compute_fixed_angle(bpp, quality),
749 norm: *norm,
750 })
751}
752
753#[must_use]
762pub fn plot_rd_svg(
763 curve: &[(f64, f64, f64)],
764 calibration: &RDCalibration,
765 title: &str,
766) -> String {
767 let frame = &calibration.frame;
768 let margin = 60.0_f64;
769 let plot_w = 600.0_f64;
770 let plot_h = 400.0_f64;
771 let total_w = plot_w + margin * 2.0;
772 let total_h = plot_h + margin * 2.0;
773
774 let x_of = |bpp: f64| -> f64 { margin + (bpp / frame.bpp_max) * plot_w };
778 let y_of = |s2: f64| -> f64 { margin + plot_h - (s2.max(0.0) / frame.s2_max) * plot_h };
779
780 let cx = x_of(frame.bpp_max);
782 let cy = y_of(0.0);
783
784 let mut svg = String::with_capacity(8192);
785
786 let _ = write!(
788 svg,
789 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {total_w} {total_h}" font-family="monospace" font-size="11">"##
790 );
791
792 let _ = write!(
794 svg,
795 r##"<rect width="{total_w}" height="{total_h}" fill="#1a1a2e"/>"##
796 );
797
798 let _ = write!(
800 svg,
801 r##"<rect x="{margin}" y="{margin}" width="{plot_w}" height="{plot_h}" fill="#16213e" stroke="#333" stroke-width="1"/>"##
802 );
803
804 for deg in (0..=90).step_by(15) {
806 let rad = (deg as f64).to_radians();
807 let q_norm = rad.sin();
808 let r_norm = rad.cos(); let scale = if r_norm.abs() > 1e-6 {
813 (1.0 / r_norm).min(if q_norm.abs() > 1e-6 {
814 1.0 / q_norm
815 } else {
816 f64::MAX
817 })
818 } else if q_norm.abs() > 1e-6 {
819 1.0 / q_norm
820 } else {
821 1.0
822 };
823
824 let bpp_far = frame.bpp_max * (1.0 - r_norm * scale).clamp(0.0, 1.0);
825 let s2_far = (frame.s2_max * q_norm * scale).clamp(0.0, frame.s2_max);
826
827 let opacity = if deg == 45 { "0.4" } else { "0.15" };
828 let color = if deg == 45 { "#ffd700" } else { "#888" };
829
830 let _ = write!(
831 svg,
832 r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{color}" stroke-width="1" stroke-dasharray="4,4" opacity="{opacity}"/>"##,
833 cx, cy,
834 x_of(bpp_far), y_of(s2_far)
835 );
836
837 let label_dist = 35.0;
839 let lx = cx - label_dist * r_norm;
840 let ly = cy - label_dist * q_norm;
841 let _ = write!(
842 svg,
843 r##"<text x="{lx:.0}" y="{ly:.0}" fill="#666" text-anchor="middle" font-size="9">{deg}°</text>"##
844 );
845 }
846
847 for bpp_tick in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5] {
849 let x = x_of(bpp_tick);
850 let _ = write!(
851 svg,
852 r##"<line x1="{x}" y1="{margin}" x2="{x}" y2="{}" stroke="#333" stroke-width="0.5"/>"##,
853 margin + plot_h
854 );
855 let _ = write!(
856 svg,
857 r##"<text x="{x}" y="{}" fill="#888" text-anchor="middle">{bpp_tick}</text>"##,
858 margin + plot_h + 16.0
859 );
860 }
861 for s2_tick in [0.0, 20.0, 40.0, 60.0, 80.0, 100.0] {
862 let y = y_of(s2_tick);
863 let _ = write!(
864 svg,
865 r##"<line x1="{margin}" y1="{y}" x2="{}" y2="{y}" stroke="#333" stroke-width="0.5"/>"##,
866 margin + plot_w
867 );
868 let _ = write!(
869 svg,
870 r##"<text x="{}" y="{}" fill="#888" text-anchor="end">{s2_tick:.0}</text>"##,
871 margin - 6.0,
872 y + 4.0
873 );
874 }
875
876 if curve.len() >= 2 {
878 let mut path = String::from("M");
879 for (i, (bpp, s2, _ba)) in curve.iter().enumerate() {
880 let sep = if i == 0 { "" } else { " L" };
881 let _ = write!(path, "{sep}{:.1},{:.1}", x_of(*bpp), y_of(*s2));
882 }
883 let _ = write!(
884 svg,
885 r##"<path d="{path}" fill="none" stroke="#e74c3c" stroke-width="2.5" stroke-linejoin="round"/>"##
886 );
887
888 for (bpp, s2, _ba) in curve {
890 let _ = write!(
891 svg,
892 r##"<circle cx="{:.1}" cy="{:.1}" r="3" fill="#e74c3c" opacity="0.8"/>"##,
893 x_of(*bpp),
894 y_of(*s2)
895 );
896 }
897 }
898
899 let s2_knee = &calibration.ssimulacra2;
901 let kx = x_of(s2_knee.bpp);
902 let ky = y_of(s2_knee.quality);
903 let _ = write!(
904 svg,
905 r##"<circle cx="{kx:.1}" cy="{ky:.1}" r="7" fill="none" stroke="#ffd700" stroke-width="2.5"/>"##
906 );
907 let _ = write!(
908 svg,
909 r##"<text x="{:.0}" y="{:.0}" fill="#ffd700" font-size="10">s2 knee {:.1}° ({:.2} bpp, s2={:.1})</text>"##,
910 kx + 12.0, ky - 4.0, s2_knee.fixed_angle, s2_knee.bpp, s2_knee.quality
911 );
912
913 let ba_knee = &calibration.butteraugli;
914 let s2_at_ba_knee = interpolate_curve_s2(curve, ba_knee.bpp).unwrap_or(50.0);
916 let bkx = x_of(ba_knee.bpp);
917 let bky = y_of(s2_at_ba_knee);
918 let _ = write!(
919 svg,
920 r##"<circle cx="{bkx:.1}" cy="{bky:.1}" r="7" fill="none" stroke="#3498db" stroke-width="2.5"/>"##
921 );
922 let _ = write!(
923 svg,
924 r##"<text x="{:.0}" y="{:.0}" fill="#3498db" font-size="10">ba knee {:.1}° ({:.2} bpp, ba={:.2})</text>"##,
925 bkx + 12.0, bky + 14.0, ba_knee.fixed_angle, ba_knee.bpp, ba_knee.quality
926 );
927
928 let (dis_lo, dis_hi) = calibration.disagreement_range();
930 let _ = write!(
931 svg,
932 r##"<rect x="{:.1}" y="{margin}" width="{:.1}" height="{plot_h}" fill="#ffd700" opacity="0.06"/>"##,
933 x_of(dis_lo),
934 x_of(dis_hi) - x_of(dis_lo)
935 );
936
937 let _ = write!(
939 svg,
940 r##"<text x="{:.0}" y="{}" fill="#ccc" text-anchor="middle" font-size="12">bpp</text>"##,
941 margin + plot_w / 2.0,
942 margin + plot_h + 35.0
943 );
944 let _ = write!(
945 svg,
946 r##"<text x="{}" y="{:.0}" fill="#ccc" text-anchor="middle" font-size="12" transform="rotate(-90,{},{:.0})">SSIMULACRA2</text>"##,
947 margin - 40.0,
948 margin + plot_h / 2.0,
949 margin - 40.0,
950 margin + plot_h / 2.0
951 );
952
953 let _ = write!(
955 svg,
956 r##"<text x="{:.0}" y="{}" fill="#eee" text-anchor="middle" font-size="14" font-weight="bold">{title}</text>"##,
957 margin + plot_w / 2.0,
958 margin - 15.0
959 );
960
961 let _ = write!(
963 svg,
964 r##"<circle cx="{cx:.0}" cy="{cy:.0}" r="4" fill="#ff6b6b"/>"##
965 );
966 let _ = write!(
967 svg,
968 r##"<text x="{:.0}" y="{:.0}" fill="#ff6b6b" font-size="9" text-anchor="end">origin</text>"##,
969 cx - 8.0, cy + 4.0
970 );
971
972 svg.push_str("</svg>");
973 svg
974}
975
976fn interpolate_curve_s2(curve: &[(f64, f64, f64)], target_bpp: f64) -> Option<f64> {
978 if curve.len() < 2 {
979 return None;
980 }
981 for w in curve.windows(2) {
982 let (b0, s0, _) = w[0];
983 let (b1, s1, _) = w[1];
984 if target_bpp >= b0 && target_bpp <= b1 && (b1 - b0).abs() > 1e-12 {
985 let t = (target_bpp - b0) / (b1 - b0);
986 return Some(s0 + t * (s1 - s0));
987 }
988 }
989 None
990}
991
992pub mod defaults {
1002 use super::{
1003 AxisRange, FixedFrame, NormalizationContext, QualityDirection, RDCalibration, RDKnee,
1004 };
1005
1006 #[must_use]
1012 pub fn mozjpeg_cid22() -> RDCalibration {
1013 let frame = FixedFrame::WEB;
1014 RDCalibration {
1015 frame,
1016 ssimulacra2: RDKnee {
1017 bpp: 0.7274,
1018 quality: 65.10,
1019 fixed_angle: frame.s2_angle(0.7274, 65.10),
1020 norm: NormalizationContext {
1021 bpp_range: AxisRange::new(0.1760, 3.6274),
1022 quality_range: AxisRange::new(-8.48, 87.99),
1023 direction: QualityDirection::HigherIsBetter,
1024 },
1025 },
1026 butteraugli: RDKnee {
1027 bpp: 0.7048,
1028 quality: 4.378,
1029 fixed_angle: frame.ba_angle(0.7048, 4.378),
1030 norm: NormalizationContext {
1031 bpp_range: AxisRange::new(0.1760, 3.6274),
1032 quality_range: AxisRange::new(1.854, 11.663),
1033 direction: QualityDirection::LowerIsBetter,
1034 },
1035 },
1036 corpus: "CID22-training".into(),
1037 codec: "mozjpeg-420-prog".into(),
1038 image_count: 209,
1039 computed_at: "2026-02-03T22:56:01Z".into(),
1040 }
1041 }
1042
1043 #[must_use]
1049 pub fn mozjpeg_clic2025() -> RDCalibration {
1050 let frame = FixedFrame::WEB;
1051 RDCalibration {
1052 frame,
1053 ssimulacra2: RDKnee {
1054 bpp: 0.4623,
1055 quality: 58.95,
1056 fixed_angle: frame.s2_angle(0.4623, 58.95),
1057 norm: NormalizationContext {
1058 bpp_range: AxisRange::new(0.1194, 3.0694),
1059 quality_range: AxisRange::new(-16.94, 87.63),
1060 direction: QualityDirection::HigherIsBetter,
1061 },
1062 },
1063 butteraugli: RDKnee {
1064 bpp: 0.3948,
1065 quality: 5.192,
1066 fixed_angle: frame.ba_angle(0.3948, 5.192),
1067 norm: NormalizationContext {
1068 bpp_range: AxisRange::new(0.1194, 3.0694),
1069 quality_range: AxisRange::new(1.895, 13.264),
1070 direction: QualityDirection::LowerIsBetter,
1071 },
1072 },
1073 corpus: "CLIC2025-training".into(),
1074 codec: "mozjpeg-420-prog".into(),
1075 image_count: 32,
1076 computed_at: "2026-02-03T23:09:01Z".into(),
1077 }
1078 }
1079}
1080
1081#[cfg(test)]
1086mod tests {
1087 use super::{
1088 defaults, AngleBin, AxisRange, BinScheme, CodecConfig, ConfiguredParetoFront,
1089 ConfiguredRDPoint, CorpusAggregate, FixedFrame, NormalizationContext, ParamValue,
1090 QualityDirection,
1091 };
1092
1093 fn make_test_curve() -> Vec<(f64, f64, f64)> {
1094 vec![
1095 (0.10, 25.0, 8.0),
1096 (0.20, 40.0, 5.5),
1097 (0.30, 52.0, 3.8),
1098 (0.50, 62.0, 2.5),
1099 (0.70, 70.0, 1.8),
1100 (1.00, 78.0, 1.2),
1101 (1.50, 84.0, 0.8),
1102 (2.00, 88.0, 0.6),
1103 (3.00, 92.0, 0.4),
1104 ]
1105 }
1106
1107 #[test]
1108 fn test_fixed_frame_s2_corner() {
1109 let f = FixedFrame::WEB;
1110 assert!(f.s2_angle(4.0, 0.0).abs() < 0.01);
1112 let ideal = f.s2_angle(0.0, 100.0);
1114 assert!(ideal > 50.0 && ideal < 53.0, "ideal angle: {ideal}");
1115 assert!((f.s2_angle(0.7274, 65.10) - 45.0).abs() < 0.1);
1117 assert!((f.s2_angle(4.0, 100.0) - 90.0).abs() < 0.01);
1119 assert!(f.s2_angle(2.0, -10.0) < 0.0);
1121 assert!(f.s2_angle(5.0, 50.0) > 90.0);
1123 }
1124
1125 #[test]
1126 fn test_fixed_frame_ba_corner() {
1127 let f = FixedFrame::WEB;
1128 assert!(f.ba_angle(4.0, 15.0).abs() < 0.01);
1130 let ideal = f.ba_angle(0.0, 0.0);
1132 assert!(ideal > 50.0 && ideal < 53.0, "ba ideal angle: {ideal}");
1133 }
1134
1135 #[test]
1136 fn test_fixed_frame_comparable() {
1137 let f = FixedFrame::WEB;
1138 let a = f.s2_angle(0.7274, 65.10); assert!((a - 45.0).abs() < 0.1);
1142 let b = f.s2_angle(2.0, 50.0);
1147 assert!(b > 45.0, "should be above knee: {b}");
1148 }
1149
1150 #[test]
1151 fn test_axis_range_normalize() {
1152 let r = AxisRange::new(0.0, 10.0);
1153 assert!((r.normalize(5.0) - 0.5).abs() < 1e-10);
1154 }
1155
1156 #[test]
1157 fn test_axis_range_roundtrip() {
1158 let r = AxisRange::new(2.0, 8.0);
1159 let val = 5.5;
1160 assert!((r.denormalize(r.normalize(val)) - val).abs() < 1e-10);
1161 }
1162
1163 #[test]
1164 fn test_quality_direction_higher_is_better() {
1165 let ctx = NormalizationContext {
1166 bpp_range: AxisRange::new(0.0, 3.0),
1167 quality_range: AxisRange::new(20.0, 100.0),
1168 direction: QualityDirection::HigherIsBetter,
1169 };
1170 assert!((ctx.normalize_quality(100.0) - 1.0).abs() < 1e-10);
1171 assert!(ctx.normalize_quality(20.0).abs() < 1e-10);
1172 }
1173
1174 #[test]
1175 fn test_quality_direction_lower_is_better() {
1176 let ctx = NormalizationContext {
1177 bpp_range: AxisRange::new(0.0, 3.0),
1178 quality_range: AxisRange::new(0.5, 12.0),
1179 direction: QualityDirection::LowerIsBetter,
1180 };
1181 assert!((ctx.normalize_quality(0.5) - 1.0).abs() < 1e-10);
1182 assert!(ctx.normalize_quality(12.0).abs() < 1e-10);
1183 }
1184
1185 #[test]
1186 fn test_knee_detection_s2() {
1187 let curve = make_test_curve();
1188 let agg = CorpusAggregate {
1189 corpus: "test".into(),
1190 codec: "test-codec".into(),
1191 curve,
1192 image_count: 1,
1193 };
1194
1195 let knee = agg.ssimulacra2_knee(&FixedFrame::WEB).expect("should find knee");
1196 assert!(knee.bpp > 0.2, "knee bpp too low: {}", knee.bpp);
1197 assert!(knee.bpp < 2.0, "knee bpp too high: {}", knee.bpp);
1198 assert!(knee.quality > 40.0, "knee s2 too low: {}", knee.quality);
1199 assert!(knee.quality < 90.0, "knee s2 too high: {}", knee.quality);
1200 assert!(knee.fixed_angle > 20.0, "angle too low: {}", knee.fixed_angle);
1202 assert!(knee.fixed_angle < 70.0, "angle too high: {}", knee.fixed_angle);
1203 }
1204
1205 #[test]
1206 fn test_knee_detection_ba() {
1207 let curve = make_test_curve();
1208 let agg = CorpusAggregate {
1209 corpus: "test".into(),
1210 codec: "test-codec".into(),
1211 curve,
1212 image_count: 1,
1213 };
1214
1215 let knee = agg.butteraugli_knee(&FixedFrame::WEB).expect("should find knee");
1216 assert!(knee.bpp > 0.2);
1217 assert!(knee.bpp < 2.0);
1218 assert!(knee.fixed_angle > 20.0);
1219 assert!(knee.fixed_angle < 70.0);
1220 }
1221
1222 #[test]
1223 fn test_calibration_disagreement_range() {
1224 let curve = make_test_curve();
1225 let agg = CorpusAggregate {
1226 corpus: "test".into(),
1227 codec: "test-codec".into(),
1228 curve,
1229 image_count: 1,
1230 };
1231
1232 let cal = agg.calibrate(&FixedFrame::WEB).expect("should calibrate");
1233 let (lo, hi) = cal.disagreement_range();
1234 assert!(lo <= hi);
1235 assert!(lo > 0.0);
1236 }
1237
1238 #[test]
1239 fn test_defaults_knee_angles() {
1240 let cal = defaults::mozjpeg_cid22();
1241 assert!(
1243 (cal.ssimulacra2.fixed_angle - 45.0).abs() < 0.5,
1244 "s2 knee angle {:.1}° should be ~45°",
1245 cal.ssimulacra2.fixed_angle
1246 );
1247 assert!(
1249 cal.butteraugli.fixed_angle > 40.0 && cal.butteraugli.fixed_angle < 55.0,
1250 "ba knee angle {:.1}° outside expected 40-55° range",
1251 cal.butteraugli.fixed_angle
1252 );
1253 let diff = (cal.ssimulacra2.fixed_angle - cal.butteraugli.fixed_angle).abs();
1255 assert!(
1256 diff < 10.0,
1257 "knee angle difference {:.1}° too large (s2={:.1}°, ba={:.1}°)",
1258 diff, cal.ssimulacra2.fixed_angle, cal.butteraugli.fixed_angle
1259 );
1260 }
1261
1262 #[test]
1263 fn test_bin_scheme_range() {
1264 let scheme = BinScheme::default_18();
1265 assert_eq!(scheme.count, 18);
1266 assert!((scheme.width - 5.0).abs() < 1e-10);
1267
1268 let bins: Vec<AngleBin> = scheme.bins().collect();
1269 assert_eq!(bins.len(), 18);
1270 assert!((bins[0].center - 2.5).abs() < 1e-10);
1271 assert!((bins[17].center - 87.5).abs() < 1e-10);
1272 }
1273
1274 #[test]
1275 fn test_bin_assignment() {
1276 let scheme = BinScheme::default_18();
1277 let bin = scheme.bin_for(45.0);
1278 assert!(bin.contains(45.0));
1279 }
1280
1281 #[test]
1282 fn test_codec_config_fingerprint() {
1283 let config = CodecConfig::new("mozjpeg-rs", "0.5.0")
1284 .with_param("quality", ParamValue::Int(75))
1285 .with_param("trellis", ParamValue::Bool(true));
1286 let fp = config.fingerprint();
1287 assert!(fp.contains("mozjpeg-rs"));
1288 assert!(fp.contains("quality=75"));
1289 }
1290
1291 #[test]
1292 fn test_configured_pareto_front() {
1293 let cal = defaults::mozjpeg_cid22();
1294
1295 let points: Vec<ConfiguredRDPoint> = vec![
1296 ConfiguredRDPoint {
1297 position: cal.position(0.3, 50.0, 4.0),
1298 config: CodecConfig::new("test", "1.0")
1299 .with_param("q", ParamValue::Int(30)),
1300 image: None,
1301 encode_time_ms: None,
1302 decode_time_ms: None,
1303 },
1304 ConfiguredRDPoint {
1305 position: cal.position(0.5, 65.0, 2.5),
1306 config: CodecConfig::new("test", "1.0")
1307 .with_param("q", ParamValue::Int(50)),
1308 image: None,
1309 encode_time_ms: None,
1310 decode_time_ms: None,
1311 },
1312 ConfiguredRDPoint {
1313 position: cal.position(1.0, 80.0, 1.0),
1314 config: CodecConfig::new("test", "1.0")
1315 .with_param("q", ParamValue::Int(80)),
1316 image: None,
1317 encode_time_ms: None,
1318 decode_time_ms: None,
1319 },
1320 ConfiguredRDPoint {
1321 position: cal.position(0.6, 60.0, 3.0),
1322 config: CodecConfig::new("test", "1.0")
1323 .with_param("q", ParamValue::Int(45)),
1324 image: None,
1325 encode_time_ms: None,
1326 decode_time_ms: None,
1327 },
1328 ];
1329
1330 let scheme = BinScheme::default_18();
1331 let front = ConfiguredParetoFront::compute(points, cal, scheme);
1332
1333 assert_eq!(front.points.len(), 3);
1335
1336 for p in &front.points {
1338 assert!(p.position.theta_s2 > 0.0, "s2 angle: {}", p.position.theta_s2);
1339 assert!(p.position.theta_ba > 0.0, "ba angle: {}", p.position.theta_ba);
1340 }
1341
1342 let best = front.best_config_for_s2(70.0).unwrap();
1343 assert_eq!(best.config.params.get("q"), Some(&ParamValue::Int(80)));
1344
1345 let best = front.best_config_for_bpp(0.5).unwrap();
1346 assert_eq!(best.config.params.get("q"), Some(&ParamValue::Int(50)));
1347 }
1348}