1#![forbid(unsafe_code)]
169
170mod blur;
171mod input;
172mod precompute;
173#[cfg(feature = "hdr-pu")]
175mod pu_xyb;
176#[doc(hidden)]
177pub mod reference_data;
178#[allow(clippy::too_many_arguments)] mod simd_ops;
180mod strip;
181mod weights;
182mod xyb_simd;
183
184pub use blur::Blur;
185pub use input::{LinearRgbImage, LinearRgbImageError, ToLinearRgb};
186pub use precompute::{CompareContext, ScalePlanesView, Ssimulacra2Reference};
187pub use strip::{
188 HALO_ROWS_DEFAULT, MIN_STRIP_HEIGHT, Ssimulacra2StripConfig, compute_ssimulacra2_strip,
189 compute_ssimulacra2_strip_with_config,
190};
191
192pub use input::{srgb_to_linear, srgb_u8_to_linear, srgb_u16_to_linear};
194
195use yuvxyb::LinearRgb;
197use yuvxyb::Xyb;
198
199pub(crate) use weights::NUM_SCALES;
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
205pub enum SimdImpl {
206 Scalar,
208 #[default]
210 Simd,
211}
212
213impl SimdImpl {
214 pub fn name(&self) -> &'static str {
216 match self {
217 SimdImpl::Scalar => "scalar",
218 SimdImpl::Simd => "simd (archmage)",
219 }
220 }
221}
222
223#[derive(Debug, Clone, Copy, Default)]
225pub struct Ssimulacra2Config {
226 pub impl_type: SimdImpl,
228}
229
230impl Ssimulacra2Config {
231 pub fn new(impl_type: SimdImpl) -> Self {
233 Self { impl_type }
234 }
235
236 pub fn simd() -> Self {
238 Self::new(SimdImpl::Simd)
239 }
240
241 pub fn scalar() -> Self {
243 Self::new(SimdImpl::Scalar)
244 }
245}
246
247#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
249pub enum Ssimulacra2Error {
250 #[error("Failed to convert input image to linear RGB")]
252 LinearRgbConversionFailed,
253
254 #[error("Source and distorted image width and height must be equal")]
256 NonMatchingImageDimensions,
257
258 #[error("Images must be at least 8x8 pixels")]
268 InvalidImageSize,
269
270 #[error(
279 "Image is too large: {actual} pixels exceeds limit of {} pixels",
280 MAX_IMAGE_PIXELS
281 )]
282 ImageTooLarge {
283 actual: usize,
285 },
286
287 #[error("Gaussian blur operation failed")]
289 GaussianBlurError,
290}
291
292pub const MAX_IMAGE_PIXELS: usize = 16_384 * 16_384;
303
304#[deprecated(
306 since = "0.8.0",
307 note = "use compute_ssimulacra2 with ToLinearRgb types instead"
308)]
309pub fn compute_frame_ssimulacra2<T, U>(source: T, distorted: U) -> Result<f64, Ssimulacra2Error>
310where
311 LinearRgb: TryFrom<T> + TryFrom<U>,
312{
313 compute_frame_ssimulacra2_impl(source, distorted, Ssimulacra2Config::default())
314}
315
316#[deprecated(
318 since = "0.8.0",
319 note = "use compute_ssimulacra2_with_config with ToLinearRgb types instead"
320)]
321pub fn compute_frame_ssimulacra2_with_config<T, U>(
322 source: T,
323 distorted: U,
324 config: Ssimulacra2Config,
325) -> Result<f64, Ssimulacra2Error>
326where
327 LinearRgb: TryFrom<T> + TryFrom<U>,
328{
329 compute_frame_ssimulacra2_impl(source, distorted, config)
330}
331
332pub fn compute_ssimulacra2<S, D>(source: S, distorted: D) -> Result<f64, Ssimulacra2Error>
354where
355 S: ToLinearRgb,
356 D: ToLinearRgb,
357{
358 compute_ssimulacra2_with_config(source, distorted, Ssimulacra2Config::default())
359}
360
361pub fn compute_ssimulacra2_with_config<S, D>(
363 source: S,
364 distorted: D,
365 config: Ssimulacra2Config,
366) -> Result<f64, Ssimulacra2Error>
367where
368 S: ToLinearRgb,
369 D: ToLinearRgb,
370{
371 let img1: LinearRgb = reflect_pad_linear(source.into_linear_rgb(), 8).into();
377 let img2: LinearRgb = reflect_pad_linear(distorted.into_linear_rgb(), 8).into();
378 compute_frame_ssimulacra2_impl(img1, img2, config)
379}
380
381#[inline]
386fn reflect_index(i: usize, n: usize) -> usize {
387 if n <= 1 {
388 return 0;
389 }
390 let period = 2 * (n - 1);
391 let mut k = i % period;
392 if k >= n {
393 k = period - k;
394 }
395 k
396}
397
398pub(crate) fn reflect_pad_linear(img: LinearRgbImage, min: usize) -> LinearRgbImage {
404 let (w, h) = (img.width(), img.height());
405 if w == 0 || h == 0 {
406 return img;
407 }
408 let (pw, ph) = (w.max(min), h.max(min));
409 if pw == w && ph == h {
410 return img;
411 }
412 let src = img.data();
413 let mut out = Vec::with_capacity(pw * ph);
414 for y in 0..ph {
415 let row = reflect_index(y, h) * w;
416 for x in 0..pw {
417 out.push(src[row + reflect_index(x, w)]);
418 }
419 }
420 LinearRgbImage::new(out, pw, ph)
421}
422
423#[derive(Clone, Copy, PartialEq, Eq)]
430enum XybFlavor {
431 CubeRoot,
432 #[cfg(feature = "hdr-pu")]
433 Pu21,
434}
435
436fn compute_frame_ssimulacra2_impl<T, U>(
437 source: T,
438 distorted: U,
439 config: Ssimulacra2Config,
440) -> Result<f64, Ssimulacra2Error>
441where
442 LinearRgb: TryFrom<T> + TryFrom<U>,
443{
444 let Ok(img1) = LinearRgb::try_from(source) else {
445 return Err(Ssimulacra2Error::LinearRgbConversionFailed);
446 };
447
448 let Ok(img2) = LinearRgb::try_from(distorted) else {
449 return Err(Ssimulacra2Error::LinearRgbConversionFailed);
450 };
451 compute_frame_flavored(img1, img2, config, XybFlavor::CubeRoot)
452}
453
454fn compute_frame_flavored(
455 mut img1: LinearRgb,
456 mut img2: LinearRgb,
457 config: Ssimulacra2Config,
458 flavor: XybFlavor,
459) -> Result<f64, Ssimulacra2Error> {
460 if img1.width() != img2.width() || img1.height() != img2.height() {
461 return Err(Ssimulacra2Error::NonMatchingImageDimensions);
462 }
463
464 if img1.width().get() < 8 || img1.height().get() < 8 {
465 return Err(Ssimulacra2Error::InvalidImageSize);
466 }
467
468 let pixels = img1
472 .width()
473 .get()
474 .checked_mul(img1.height().get())
475 .ok_or(Ssimulacra2Error::ImageTooLarge { actual: usize::MAX })?;
476 if pixels > MAX_IMAGE_PIXELS {
477 return Err(Ssimulacra2Error::ImageTooLarge { actual: pixels });
478 }
479
480 let mut width = img1.width().get();
481 let mut height = img1.height().get();
482 let impl_type = config.impl_type;
483
484 let scales_n = weights::count_scales(width, height);
487
488 let alloc_plane = || vec![0.0f32; width * height];
490 let alloc_3planes = || [alloc_plane(), alloc_plane(), alloc_plane()];
491
492 let mut mul = alloc_3planes();
493 let mut sigma1_sq = alloc_3planes();
494 let mut sigma2_sq = alloc_3planes();
495 let mut sigma12 = alloc_3planes();
496 let mut mu1 = alloc_3planes();
497 let mut mu2 = alloc_3planes();
498 let mut img1_planar = alloc_3planes();
499 let mut img2_planar = alloc_3planes();
500
501 let mut blur = Blur::with_simd_impl(width, height, impl_type);
502 let mut msssim = Msssim::default();
503
504 for scale in 0..NUM_SCALES {
505 if width < 8 || height < 8 {
506 break;
507 }
508
509 if scale > 0 {
510 img1 = downscale_by_2(&img1);
511 img2 = downscale_by_2(&img2);
512 width = img1.width().get();
513 height = img2.height().get();
514 }
515
516 let size = width * height;
518 for buf in [
519 &mut mul,
520 &mut sigma1_sq,
521 &mut sigma2_sq,
522 &mut sigma12,
523 &mut mu1,
524 &mut mu2,
525 &mut img1_planar,
526 &mut img2_planar,
527 ] {
528 for c in buf.iter_mut() {
529 c.truncate(size);
530 }
531 }
532 blur.shrink_to(width, height);
533
534 let (img1_xyb, img2_xyb) = match flavor {
535 XybFlavor::CubeRoot => {
536 let mut a = linear_rgb_to_xyb(img1.clone(), impl_type);
537 let mut b = linear_rgb_to_xyb(img2.clone(), impl_type);
538 make_positive_xyb(&mut a);
539 make_positive_xyb(&mut b);
540 (a, b)
541 }
542 #[cfg(feature = "hdr-pu")]
544 XybFlavor::Pu21 => (
545 linear_nits_to_pu_xyb(img1.clone()),
546 linear_nits_to_pu_xyb(img2.clone()),
547 ),
548 };
549
550 xyb_to_planar_into(&img1_xyb, &mut img1_planar);
551 xyb_to_planar_into(&img2_xyb, &mut img2_planar);
552
553 image_multiply(&img1_planar, &img1_planar, &mut mul, impl_type);
554 blur.blur_into(&mul, &mut sigma1_sq);
555
556 image_multiply(&img2_planar, &img2_planar, &mut mul, impl_type);
557 blur.blur_into(&mul, &mut sigma2_sq);
558
559 image_multiply(&img1_planar, &img2_planar, &mut mul, impl_type);
560 blur.blur_into(&mul, &mut sigma12);
561
562 blur.blur_into(&img1_planar, &mut mu1);
563 blur.blur_into(&img2_planar, &mut mu2);
564
565 let avg_ssim = ssim_map(
566 scales_n, scale, width, height, &mu1, &mu2, &sigma1_sq, &sigma2_sq, &sigma12, impl_type,
567 );
568 let avg_edgediff = edge_diff_map(
569 scales_n,
570 scale,
571 width,
572 height,
573 &img1_planar,
574 &mu1,
575 &img2_planar,
576 &mu2,
577 impl_type,
578 );
579 msssim.scales.push(MsssimScale {
580 avg_ssim,
581 avg_edgediff,
582 });
583 }
584
585 Ok(msssim.score())
586}
587
588#[cfg(feature = "hdr-pu")]
590fn linear_nits_to_pu_xyb(linear_nits: LinearRgb) -> Xyb {
591 let width = linear_nits.width();
592 let height = linear_nits.height();
593 let mut data = linear_nits.into_data();
594 pu_xyb::linear_nits_to_pu_xyb(&mut data);
595 Xyb::new(data, width, height).expect("XYB construction should not fail")
596}
597
598#[cfg(feature = "hdr-pu")]
611pub fn compute_ssimulacra2_pu_nits(
612 source_nits: LinearRgbImage,
613 distorted_nits: LinearRgbImage,
614) -> Result<f64, Ssimulacra2Error> {
615 let img1: LinearRgb = reflect_pad_linear(source_nits, 8).into();
616 let img2: LinearRgb = reflect_pad_linear(distorted_nits, 8).into();
617 compute_frame_flavored(img1, img2, Ssimulacra2Config::default(), XybFlavor::Pu21)
618}
619
620fn linear_rgb_to_xyb(linear_rgb: LinearRgb, impl_type: SimdImpl) -> Xyb {
622 match impl_type {
623 SimdImpl::Scalar => Xyb::from(linear_rgb),
624 SimdImpl::Simd => {
625 let width = linear_rgb.width(); let height = linear_rgb.height(); let mut data = linear_rgb.into_data();
628 xyb_simd::linear_rgb_to_xyb_simd(&mut data);
629 Xyb::new(data, width, height).expect("XYB construction should not fail")
630 }
631 }
632}
633
634pub(crate) fn linear_rgb_to_xyb_simd(linear_rgb: LinearRgb) -> Xyb {
637 linear_rgb_to_xyb(linear_rgb, SimdImpl::Simd)
638}
639
640pub(crate) fn make_positive_xyb(xyb: &mut Xyb) {
641 for pix in xyb.data_mut().iter_mut() {
642 pix[2] = (pix[2] - pix[1]) + 0.55;
643 pix[0] = (pix[0]).mul_add(14.0, 0.42);
644 pix[1] += 0.01;
645 }
646}
647
648pub(crate) fn xyb_to_planar(xyb: &Xyb) -> [Vec<f32>; 3] {
649 let size = xyb.width().get() * xyb.height().get();
650 let mut out = [vec![0.0f32; size], vec![0.0f32; size], vec![0.0f32; size]];
651 xyb_to_planar_into(xyb, &mut out);
652 out
653}
654
655pub(crate) fn xyb_to_planar_into(xyb: &Xyb, out: &mut [Vec<f32>; 3]) {
657 let [out0, out1, out2] = out;
658 for (((i, o0), o1), o2) in xyb
659 .data()
660 .iter()
661 .copied()
662 .zip(out0.iter_mut())
663 .zip(out1.iter_mut())
664 .zip(out2.iter_mut())
665 {
666 *o0 = i[0];
667 *o1 = i[1];
668 *o2 = i[2];
669 }
670}
671
672pub(crate) fn image_multiply(
673 img1: &[Vec<f32>; 3],
674 img2: &[Vec<f32>; 3],
675 out: &mut [Vec<f32>; 3],
676 impl_type: SimdImpl,
677) {
678 match impl_type {
679 SimdImpl::Scalar => image_multiply_scalar(img1, img2, out),
680 SimdImpl::Simd => simd_ops::image_multiply_simd(img1, img2, out),
681 }
682}
683
684fn image_multiply_scalar(img1: &[Vec<f32>; 3], img2: &[Vec<f32>; 3], out: &mut [Vec<f32>; 3]) {
685 for ((plane1, plane2), out_plane) in img1.iter().zip(img2.iter()).zip(out.iter_mut()) {
686 for ((&p1, &p2), o) in plane1.iter().zip(plane2.iter()).zip(out_plane.iter_mut()) {
687 *o = p1 * p2;
688 }
689 }
690}
691
692pub(crate) fn downscale_by_2(in_data: &LinearRgb) -> LinearRgb {
693 use std::num::NonZeroUsize;
694 const SCALE: usize = 2;
695 let in_w = in_data.width().get();
696 let in_h = in_data.height().get();
697 let out_w = in_w.div_ceil(SCALE);
698 let out_h = in_h.div_ceil(SCALE);
699 let mut out_data = vec![[0.0f32; 3]; out_w * out_h];
700 let normalize = 1.0f32 / (SCALE * SCALE) as f32;
701
702 let in_data = &in_data.data();
703 for oy in 0..out_h {
704 for ox in 0..out_w {
705 for c in 0..3 {
706 let mut sum = 0f32;
707 for iy in 0..SCALE {
708 for ix in 0..SCALE {
709 let x = (ox * SCALE + ix).min(in_w - 1);
710 let y = (oy * SCALE + iy).min(in_h - 1);
711 sum += in_data[y * in_w + x][c];
712 }
713 }
714 out_data[oy * out_w + ox][c] = sum * normalize;
715 }
716 }
717 }
718
719 LinearRgb::new(
720 out_data,
721 NonZeroUsize::new(out_w).expect("out_w must be nonzero"),
722 NonZeroUsize::new(out_h).expect("out_h must be nonzero"),
723 )
724 .expect("Resolution and data size match")
725}
726
727#[allow(clippy::too_many_arguments)]
728pub(crate) fn ssim_map(
729 scales_n: usize,
730 scale_idx: usize,
731 width: usize,
732 height: usize,
733 m1: &[Vec<f32>; 3],
734 m2: &[Vec<f32>; 3],
735 s11: &[Vec<f32>; 3],
736 s22: &[Vec<f32>; 3],
737 s12: &[Vec<f32>; 3],
738 impl_type: SimdImpl,
739) -> [f64; 3 * 2] {
740 match impl_type {
741 SimdImpl::Scalar => {
742 ssim_map_scalar(scales_n, scale_idx, width, height, m1, m2, s11, s22, s12)
743 }
744 SimdImpl::Simd => {
745 simd_ops::ssim_map_simd(scales_n, scale_idx, width, height, m1, m2, s11, s22, s12)
746 }
747 }
748}
749
750#[allow(clippy::too_many_arguments)]
751fn ssim_map_scalar(
752 scales_n: usize,
753 scale_idx: usize,
754 width: usize,
755 height: usize,
756 m1: &[Vec<f32>; 3],
757 m2: &[Vec<f32>; 3],
758 s11: &[Vec<f32>; 3],
759 s22: &[Vec<f32>; 3],
760 s12: &[Vec<f32>; 3],
761) -> [f64; 3 * 2] {
762 const C2: f32 = 0.0009f32;
763
764 let one_per_pixels = 1.0f64 / (width * height) as f64;
765 let mut plane_averages = [0f64; 3 * 2];
766 let skip_table = weights::SSIM_HAS_WEIGHT[scales_n.min(NUM_SCALES)];
767
768 for c in 0..3 {
769 if scale_idx < NUM_SCALES && !skip_table[c][scale_idx] {
772 continue;
773 }
774 let mut sum_d = 0.0f64;
775 let mut sum_d4 = 0.0f64;
776 for (row_m1, (row_m2, (row_s11, (row_s22, row_s12)))) in m1[c].chunks_exact(width).zip(
777 m2[c].chunks_exact(width).zip(
778 s11[c]
779 .chunks_exact(width)
780 .zip(s22[c].chunks_exact(width).zip(s12[c].chunks_exact(width))),
781 ),
782 ) {
783 for x in 0..width {
784 let mu1 = row_m1[x];
785 let mu2 = row_m2[x];
786 let mu11 = mu1 * mu1;
787 let mu22 = mu2 * mu2;
788 let mu12 = mu1 * mu2;
789 let mu_diff = mu1 - mu2;
790
791 let num_m = mu_diff.mul_add(-mu_diff, 1.0f32);
792 let num_s = 2.0f32.mul_add(row_s12[x] - mu12, C2);
793 let denom_s = (row_s11[x] - mu11) + (row_s22[x] - mu22) + C2;
794 let d = (1.0f32 - (num_m * num_s) / denom_s).max(0.0f32);
795 let d2 = d * d;
796 let d4 = d2 * d2;
797 sum_d += f64::from(d);
798 sum_d4 += f64::from(d4);
799 }
800 }
801 plane_averages[c * 2] = one_per_pixels * sum_d;
802 plane_averages[c * 2 + 1] = (one_per_pixels * sum_d4).sqrt().sqrt();
803 }
804
805 plane_averages
806}
807
808#[allow(clippy::too_many_arguments)]
809pub(crate) fn edge_diff_map(
810 scales_n: usize,
811 scale_idx: usize,
812 width: usize,
813 height: usize,
814 img1: &[Vec<f32>; 3],
815 mu1: &[Vec<f32>; 3],
816 img2: &[Vec<f32>; 3],
817 mu2: &[Vec<f32>; 3],
818 impl_type: SimdImpl,
819) -> [f64; 3 * 4] {
820 match impl_type {
821 SimdImpl::Scalar => {
822 edge_diff_map_scalar(scales_n, scale_idx, width, height, img1, mu1, img2, mu2)
823 }
824 SimdImpl::Simd => {
825 simd_ops::edge_diff_map_simd(scales_n, scale_idx, width, height, img1, mu1, img2, mu2)
826 }
827 }
828}
829
830#[allow(clippy::too_many_arguments)]
831fn edge_diff_map_scalar(
832 scales_n: usize,
833 scale_idx: usize,
834 width: usize,
835 height: usize,
836 img1: &[Vec<f32>; 3],
837 mu1: &[Vec<f32>; 3],
838 img2: &[Vec<f32>; 3],
839 mu2: &[Vec<f32>; 3],
840) -> [f64; 3 * 4] {
841 let one_per_pixels = 1.0f64 / (width * height) as f64;
842 let mut plane_averages = [0f64; 3 * 4];
843 let skip_table = weights::EDGE_HAS_WEIGHT[scales_n.min(NUM_SCALES)];
844
845 for c in 0..3 {
846 if scale_idx < NUM_SCALES && !skip_table[c][scale_idx] {
847 continue;
848 }
849 let mut sum1 = [0.0f64; 4];
850 for (row1, (row2, (rowm1, rowm2))) in img1[c].chunks_exact(width).zip(
851 img2[c]
852 .chunks_exact(width)
853 .zip(mu1[c].chunks_exact(width).zip(mu2[c].chunks_exact(width))),
854 ) {
855 for x in 0..width {
856 let d1: f64 = (1.0 + f64::from((row2[x] - rowm2[x]).abs()))
857 / (1.0 + f64::from((row1[x] - rowm1[x]).abs()))
858 - 1.0;
859
860 let artifact = d1.max(0.0);
861 sum1[0] += artifact;
862 sum1[1] += artifact.powi(4);
863
864 let detail_lost = (-d1).max(0.0);
865 sum1[2] += detail_lost;
866 sum1[3] += detail_lost.powi(4);
867 }
868 }
869 plane_averages[c * 4] = one_per_pixels * sum1[0];
870 plane_averages[c * 4 + 1] = (one_per_pixels * sum1[1]).sqrt().sqrt();
871 plane_averages[c * 4 + 2] = one_per_pixels * sum1[2];
872 plane_averages[c * 4 + 3] = (one_per_pixels * sum1[3]).sqrt().sqrt();
873 }
874
875 plane_averages
876}
877
878#[derive(Debug, Clone, Default)]
879pub(crate) struct Msssim {
880 pub scales: Vec<MsssimScale>,
881}
882
883#[derive(Debug, Clone, Copy, Default)]
884pub(crate) struct MsssimScale {
885 pub avg_ssim: [f64; 3 * 2],
886 pub avg_edgediff: [f64; 3 * 4],
887}
888
889impl Msssim {
890 pub fn score(&self) -> f64 {
891 use weights::WEIGHT;
892 let mut ssim = 0.0f64;
893
894 let mut i = 0usize;
895 for c in 0..3 {
896 for scale in &self.scales {
897 for n in 0..2 {
898 ssim = WEIGHT[i].mul_add(scale.avg_ssim[c * 2 + n].abs(), ssim);
899 i += 1;
900 ssim = WEIGHT[i].mul_add(scale.avg_edgediff[c * 4 + n].abs(), ssim);
901 i += 1;
902 ssim = WEIGHT[i].mul_add(scale.avg_edgediff[c * 4 + n + 2].abs(), ssim);
903 i += 1;
904 }
905 }
906 }
907
908 ssim *= 0.956_238_261_683_484_4_f64;
909 ssim = (6.248_496_625_763_138e-5 * ssim * ssim).mul_add(
910 ssim,
911 2.326_765_642_916_932f64.mul_add(ssim, -0.020_884_521_182_843_837 * ssim * ssim),
912 );
913
914 if ssim > 0.0f64 {
915 ssim = ssim
916 .powf(0.627_633_646_783_138_7)
917 .mul_add(-10.0f64, 100.0f64);
918 } else {
919 ssim = 100.0f64;
920 }
921
922 ssim
923 }
924}
925
926#[cfg(test)]
927#[allow(deprecated)]
928mod tests {
929 use std::path::PathBuf;
930
931 use super::*;
932 use yuvxyb::{ColorPrimaries, Rgb, TransferCharacteristic};
933
934 #[test]
935 fn test_ssimulacra2() {
936 let source = image::open(
937 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
938 .join("test_data")
939 .join("tank_source.png"),
940 )
941 .unwrap();
942 let distorted = image::open(
943 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
944 .join("test_data")
945 .join("tank_distorted.png"),
946 )
947 .unwrap();
948 let source_data = source
949 .to_rgb32f()
950 .chunks_exact(3)
951 .map(|chunk| [chunk[0], chunk[1], chunk[2]])
952 .collect::<Vec<_>>();
953 let source_data = Xyb::try_from(
954 Rgb::new(
955 source_data,
956 std::num::NonZeroUsize::new(source.width() as usize).unwrap(),
957 std::num::NonZeroUsize::new(source.height() as usize).unwrap(),
958 TransferCharacteristic::SRGB,
959 ColorPrimaries::BT709,
960 )
961 .unwrap(),
962 )
963 .unwrap();
964 let distorted_data = distorted
965 .to_rgb32f()
966 .chunks_exact(3)
967 .map(|chunk| [chunk[0], chunk[1], chunk[2]])
968 .collect::<Vec<_>>();
969 let distorted_data = Xyb::try_from(
970 Rgb::new(
971 distorted_data,
972 std::num::NonZeroUsize::new(distorted.width() as usize).unwrap(),
973 std::num::NonZeroUsize::new(distorted.height() as usize).unwrap(),
974 TransferCharacteristic::SRGB,
975 ColorPrimaries::BT709,
976 )
977 .unwrap(),
978 )
979 .unwrap();
980 let result = compute_frame_ssimulacra2(source_data, distorted_data).unwrap();
981 let expected = 17.398_505_f64;
982 assert!(
983 (result - expected).abs() < 0.25f64,
984 "Result {result:.6} not equal to expected {expected:.6}",
985 );
986 }
987
988 #[test]
989 fn test_xyb_simd_vs_yuvxyb() {
990 use yuvxyb::{ColorPrimaries, TransferCharacteristic};
991
992 let source = image::open(
993 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
994 .join("test_data")
995 .join("tank_source.png"),
996 )
997 .unwrap();
998
999 let source_data: Vec<[f32; 3]> = source
1000 .to_rgb32f()
1001 .chunks_exact(3)
1002 .map(|chunk| [chunk[0], chunk[1], chunk[2]])
1003 .collect();
1004
1005 let width = source.width() as usize;
1006 let height = source.height() as usize;
1007 let nz_width = std::num::NonZeroUsize::new(width).unwrap();
1008 let nz_height = std::num::NonZeroUsize::new(height).unwrap();
1009
1010 let rgb_for_yuvxyb = Rgb::new(
1011 source_data.clone(),
1012 nz_width,
1013 nz_height,
1014 TransferCharacteristic::SRGB,
1015 ColorPrimaries::BT709,
1016 )
1017 .unwrap();
1018 let lrgb_for_yuvxyb = yuvxyb::LinearRgb::try_from(rgb_for_yuvxyb).unwrap();
1019 let xyb_yuvxyb = yuvxyb::Xyb::from(lrgb_for_yuvxyb);
1020
1021 let rgb_for_simd = Rgb::new(
1022 source_data,
1023 nz_width,
1024 nz_height,
1025 TransferCharacteristic::SRGB,
1026 ColorPrimaries::BT709,
1027 )
1028 .unwrap();
1029 let lrgb_for_simd = LinearRgb::try_from(rgb_for_simd).unwrap();
1030 let xyb_simd = linear_rgb_to_xyb_simd(lrgb_for_simd);
1031
1032 let mut max_diff = [0.0f32; 3];
1033 for (yuvxyb_pix, simd_pix) in xyb_yuvxyb.data().iter().zip(xyb_simd.data().iter()) {
1034 for c in 0..3 {
1035 let diff = (yuvxyb_pix[c] - simd_pix[c]).abs();
1036 max_diff[c] = max_diff[c].max(diff);
1037 }
1038 }
1039
1040 assert!(
1041 max_diff[0] < 1e-5 && max_diff[1] < 1e-5 && max_diff[2] < 1e-5,
1042 "SIMD XYB differs from yuvxyb: max_diff={:?}",
1043 max_diff
1044 );
1045 }
1046
1047 fn make_linear_rgb(width: usize, height: usize) -> LinearRgb {
1051 use std::num::NonZeroUsize;
1052 let data = vec![[0.5f32, 0.5, 0.5]; width * height];
1053 LinearRgb::new(
1054 data,
1055 NonZeroUsize::new(width).unwrap(),
1056 NonZeroUsize::new(height).unwrap(),
1057 )
1058 .unwrap()
1059 }
1060
1061 #[test]
1062 fn test_compute_rejects_too_large_input() {
1063 const { assert!(MAX_IMAGE_PIXELS >= 8 * 8) };
1079 let err = Ssimulacra2Error::ImageTooLarge {
1080 actual: MAX_IMAGE_PIXELS + 1,
1081 };
1082 let msg = format!("{err}");
1083 assert!(msg.contains("too large"), "unexpected message: {msg}");
1084 assert!(
1085 msg.contains(&MAX_IMAGE_PIXELS.to_string()),
1086 "message should reference the limit: {msg}"
1087 );
1088 }
1089
1090 #[test]
1091 fn test_compute_accepts_small_input() {
1092 let img = make_linear_rgb(16, 16);
1095 let score = compute_ssimulacra2_with_config(img.clone(), img, Ssimulacra2Config::default())
1096 .expect("16x16 grey image must be accepted");
1097 assert!(
1098 (score - 100.0).abs() < 0.01,
1099 "identical images should score 100, got {score}"
1100 );
1101 }
1102
1103 #[test]
1104 fn test_sub_8_reflect_pads_instead_of_rejecting() {
1105 use std::num::NonZeroUsize;
1106 for (w, h) in [(4usize, 4usize), (1, 1), (3, 7), (7, 3)] {
1110 let img = make_linear_rgb(w, h);
1111 let score =
1112 compute_ssimulacra2_with_config(img.clone(), img, Ssimulacra2Config::default())
1113 .unwrap_or_else(|e| panic!("{w}x{h} must score, got {e:?}"));
1114 assert!(
1115 (score - 100.0).abs() < 0.01,
1116 "identical {w}x{h} should score ~100, got {score}"
1117 );
1118 }
1119 let a = make_linear_rgb(5, 5);
1121 let b = LinearRgb::new(
1122 vec![[0.9f32, 0.1, 0.2]; 25],
1123 NonZeroUsize::new(5).unwrap(),
1124 NonZeroUsize::new(5).unwrap(),
1125 )
1126 .unwrap();
1127 let s = compute_ssimulacra2_with_config(a, b, Ssimulacra2Config::default())
1128 .expect("5x5 differing pair must score");
1129 assert!(s.is_finite() && s < 100.0, "5x5 differing score {s}");
1130 }
1131}