1use super::{MotionCompensationMode, PrefilterMode, prefilter};
2
3pub(super) const NLM_NORM: f32 = 255.0 * 255.0;
9
10pub(super) const NLM_LEGACY: f32 = 3.0;
13
14const HQ_DEFAULT_STRENGTH_LUMA: [f32; 9] = [0.45, 0.45, 0.42, 0.42, 0.35, 0.35, 0.35, 0.30, 0.30];
20
21const HQ_DEFAULT_STRENGTH_CHROMA: [f32; 9] = [1.00, 0.85, 0.70, 0.70, 0.70, 0.70, 0.70, 0.70, 0.70];
26
27pub fn hq_default_strength(channels: ChannelMode, temporal_radius: u32) -> f32 {
68 let idx = temporal_radius.min(MAX_TEMPORAL_RADIUS) as usize;
69 match channels {
70 ChannelMode::Luma | ChannelMode::Yuv => HQ_DEFAULT_STRENGTH_LUMA[idx],
71 ChannelMode::Chroma => HQ_DEFAULT_STRENGTH_CHROMA[idx],
72 }
73}
74
75pub const MIN_FRAME_DIM: u32 = 3;
81
82pub fn validate_dimensions(width: u32, height: u32) -> Result<(), anyhow::Error> {
86 if width < MIN_FRAME_DIM || height < MIN_FRAME_DIM {
87 anyhow::bail!(
88 "frame dimensions {width}x{height} are below the supported minimum of \
89 {MIN_FRAME_DIM}x{MIN_FRAME_DIM}, because the noise estimate needs at \
90 least one interior pixel"
91 );
92 }
93 Ok(())
94}
95
96pub(super) const SEPARABLE_THRESHOLD: u32 = 8;
99
100pub const MAX_PATCH_RADIUS: u32 = 16;
105
106pub const MAX_SEARCH_RADIUS: u32 = 8;
122
123pub const MAX_TEMPORAL_RADIUS: u32 = 8;
129
130pub const MAX_BILATERAL_RADIUS: u32 = 22;
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum ChannelMode {
150 Luma,
152 Chroma,
154 Yuv,
156}
157
158impl ChannelMode {
159 pub fn count(self) -> u32 {
161 match self {
162 ChannelMode::Luma => 1,
163 ChannelMode::Chroma => 2,
164 ChannelMode::Yuv => 3,
165 }
166 }
167
168 pub fn storage_count(self) -> u32 {
174 match self {
175 ChannelMode::Luma => 1,
176 ChannelMode::Chroma => 2,
177 ChannelMode::Yuv => 4,
178 }
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq)]
188pub struct HqParams {
189 pub auto_strength: bool,
194 pub noise_floor: bool,
199 pub sigma_override: Option<f32>,
209 pub temporal_confidence: bool,
216 pub thsad_scale: f32,
222 pub sigma_scale: f32,
231}
232
233impl Default for HqParams {
234 fn default() -> Self {
235 Self {
236 auto_strength: true,
237 noise_floor: true,
238 sigma_override: None,
239 temporal_confidence: true,
240 thsad_scale: 1.0,
241 sigma_scale: 1.0,
242 }
243 }
244}
245
246impl HqParams {
247 pub fn with_sigma(sigma: f32) -> Self {
250 Self {
251 sigma_override: Some(sigma),
252 ..Self::default()
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct NlmParams {
260 pub temporal_radius: u32,
265 pub search_radius: u32,
268 pub patch_radius: u32,
271 pub strength: f32,
273 pub self_weight: f32,
278 pub channels: ChannelMode,
280 pub prefilter: PrefilterMode,
286 pub motion_compensation: MotionCompensationMode,
294 pub hq: Option<HqParams>,
296}
297
298impl Default for NlmParams {
299 fn default() -> Self {
300 Self {
301 temporal_radius: 0,
302 search_radius: 2,
303 patch_radius: 4,
304 strength: 1.2,
305 self_weight: 1.0,
306 channels: ChannelMode::Yuv,
307 prefilter: PrefilterMode::None,
308 motion_compensation: MotionCompensationMode::None,
309 hq: None,
310 }
311 }
312}
313
314impl NlmParams {
315 pub(super) fn effective_strength_with(&self, sigma_eff: Option<f32>) -> f32 {
323 match (self.hq, sigma_eff) {
324 (Some(hq), Some(sigma)) if hq.auto_strength => self.strength * sigma * 255.0,
325 _ => self.strength,
326 }
327 }
328
329 pub fn h2_inv_norm_with(&self, sigma_eff: Option<f32>) -> f32 {
335 let s_size = (2 * self.patch_radius + 1) * (2 * self.patch_radius + 1);
336 let s = self.effective_strength_with(sigma_eff);
337 NLM_NORM / (NLM_LEGACY * s * s * s_size as f32)
338 }
339
340 pub fn h2_inv_norm(&self) -> f32 {
346 self.h2_inv_norm_with(self.hq.and_then(|hq| hq.sigma_override))
347 }
348
349 pub(super) fn noise_offset_with(&self, sigmas: Option<&[f32]>) -> f32 {
358 match (self.hq, sigmas) {
359 (Some(hq), Some(sigmas)) if hq.noise_floor => {
360 let s_size = (2 * self.patch_radius + 1) * (2 * self.patch_radius + 1);
361 let scale = channel_scale(self.channels);
362 let count = self.channels.count() as usize;
363 let sum_sq: f32 = sigmas.iter().take(count).map(|&s| s * s).sum();
364 2.0 * scale * sum_sq * s_size as f32
365 },
366 _ => 0.0,
367 }
368 }
369
370 pub(super) fn noise_offset(&self) -> f32 {
376 match self.hq.and_then(|hq| hq.sigma_override) {
377 Some(sigma) => {
378 let sigmas = [sigma; 3];
379 self.noise_offset_with(Some(&sigmas[..self.channels.count() as usize]))
380 },
381 None => 0.0,
382 }
383 }
384
385 pub(super) fn total_frames(&self) -> u32 {
386 1 + 2 * self.temporal_radius
387 }
388
389 pub fn validate(&self) -> Result<(), anyhow::Error> {
396 if self.patch_radius > MAX_PATCH_RADIUS {
397 anyhow::bail!(
398 "patch_radius={} exceeds the supported maximum of {}, because larger \
399 patches exhaust on-chip shared memory in the fused and windowed kernels",
400 self.patch_radius,
401 MAX_PATCH_RADIUS,
402 );
403 }
404
405 if self.search_radius > MAX_SEARCH_RADIUS {
406 anyhow::bail!(
407 "search_radius={} exceeds the supported maximum of {}. The windowed \
408 kernel's search window loop is fully unrolled, so both its compiled \
409 size and its build time grow with search_radius",
410 self.search_radius,
411 MAX_SEARCH_RADIUS,
412 );
413 }
414
415 if self.temporal_radius > MAX_TEMPORAL_RADIUS {
416 anyhow::bail!(
417 "temporal_radius={} exceeds the supported maximum of {}, because the \
418 ring buffer grows in step with the window size",
419 self.temporal_radius,
420 MAX_TEMPORAL_RADIUS,
421 );
422 }
423
424 if !(self.strength.is_finite() && self.strength > 0.0) {
425 anyhow::bail!(
426 "strength must be finite and greater than 0, got {}. A strength of 0 \
427 produces an infinite Welsch normalisation factor",
428 self.strength,
429 );
430 }
431
432 if !self.self_weight.is_finite() || self.self_weight < 0.0 {
433 anyhow::bail!(
434 "self_weight must be finite and 0 or greater, got {}",
435 self.self_weight,
436 );
437 }
438
439 if let Some(hq) = self.hq
440 && let Some(sigma) = hq.sigma_override
441 && (!sigma.is_finite() || sigma <= 0.0 || sigma > 1.0)
442 {
443 anyhow::bail!(
444 "hq sigma_override must be finite and in (0, 1] in normalised units, got {}",
445 sigma,
446 );
447 }
448
449 if let Some(hq) = self.hq
450 && !(hq.thsad_scale.is_finite() && hq.thsad_scale > 0.0)
451 {
452 anyhow::bail!(
453 "hq thsad_scale must be finite and greater than 0, got {}. A thsad_scale \
454 of 0 collapses every block's confidence to zero no matter how well it \
455 matches",
456 hq.thsad_scale,
457 );
458 }
459
460 if let Some(hq) = self.hq
461 && !(hq.sigma_scale.is_finite() && (0.1..=10.0).contains(&hq.sigma_scale))
462 {
463 anyhow::bail!(
464 "hq sigma_scale must be finite and in [0.1, 10.0], got {}",
465 hq.sigma_scale,
466 );
467 }
468
469 if let PrefilterMode::Bilateral { sigma_s, sigma_r } = self.prefilter {
470 if !sigma_s.is_finite() || sigma_s <= 0.0 {
471 anyhow::bail!(
472 "bilateral prefilter sigma_s must be finite and greater than 0, got \
473 {}. A sigma_s of 0 produces an infinite spatial-weight \
474 normalisation factor",
475 sigma_s,
476 );
477 }
478 if !sigma_r.is_finite() || sigma_r <= 0.0 {
479 anyhow::bail!(
480 "bilateral prefilter sigma_r must be finite and greater than 0, got \
481 {}. A sigma_r of 0 produces an infinite range-weight normalisation \
482 factor, which turns the centre tap into NaN",
483 sigma_r,
484 );
485 }
486 let bilateral_radius = prefilter::bilateral_radius(sigma_s);
497 if bilateral_radius > MAX_BILATERAL_RADIUS {
498 anyhow::bail!(
499 "bilateral prefilter sigma_s={} implies a shared-memory tile radius \
500 of {}, from radius = ceil(2 * sigma_s) with a minimum of 1. That \
501 is past the supported maximum of {}, and larger radii exhaust \
502 on-chip shared memory in the bilateral kernel",
503 sigma_s,
504 bilateral_radius,
505 MAX_BILATERAL_RADIUS,
506 );
507 }
508 if !prefilter::inv_two_sigma_sq(sigma_s).is_finite() {
518 anyhow::bail!(
519 "bilateral prefilter sigma_s is too small, got {}. Squaring it \
520 underflows to 0 in f32, which makes the spatial-weight \
521 normalisation factor infinite",
522 sigma_s,
523 );
524 }
525 if !prefilter::inv_two_sigma_sq(sigma_r).is_finite() {
526 anyhow::bail!(
527 "bilateral prefilter sigma_r is too small, got {}. Squaring it \
528 underflows to 0 in f32, which makes the range-weight normalisation \
529 factor infinite and the centre tap NaN",
530 sigma_r,
531 );
532 }
533 }
534
535 if let PrefilterMode::NlmSpatial { strength_scale } = self.prefilter {
536 if !strength_scale.is_finite() || strength_scale <= 0.0 {
537 anyhow::bail!(
538 "nlm pilot strength_scale must be finite and greater than 0, got {}",
539 strength_scale,
540 );
541 }
542 if self.patch_radius > SEPARABLE_THRESHOLD {
543 anyhow::bail!(
544 "the nlm pilot uses the windowed spatial kernel, which supports \
545 patch_radius up to {} (got {})",
546 SEPARABLE_THRESHOLD,
547 self.patch_radius,
548 );
549 }
550 }
551
552 self.motion_compensation.validate()?;
553
554 Ok(())
555 }
556}
557
558pub(super) fn channel_scale(channels: ChannelMode) -> f32 {
564 match channels {
565 ChannelMode::Luma => 3.0,
566 ChannelMode::Chroma => 1.5,
567 ChannelMode::Yuv => 1.0,
568 }
569}
570
571pub(super) fn sigma_eff(sigmas: &[f32], channels: ChannelMode) -> f32 {
579 let count = channels.count() as usize;
580 let sum_sq: f32 = sigmas.iter().take(count).map(|&s| s * s).sum();
581 (sum_sq / count as f32).sqrt()
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn noise_offset_scales_with_sigma_and_patch_size() {
590 let sigma = 4.0 / 255.0;
591 let params = NlmParams {
592 patch_radius: 4,
593 hq: Some(HqParams::with_sigma(sigma)),
594 ..NlmParams::default()
595 };
596
597 let expected = 6.0 * sigma * sigma * 81.0;
598 assert!(
599 (params.noise_offset() - expected).abs() < 1e-6,
600 "expected {expected}, got {}",
601 params.noise_offset()
602 );
603 }
604
605 #[test]
606 fn noise_offset_zero_without_noise_floor() {
607 let params = NlmParams {
608 hq: Some(HqParams {
609 auto_strength: true,
610 noise_floor: false,
611 sigma_override: Some(4.0 / 255.0),
612 temporal_confidence: true,
613 thsad_scale: 1.0,
614 sigma_scale: 1.0,
615 }),
616 ..NlmParams::default()
617 };
618
619 assert_eq!(params.noise_offset(), 0.0);
620 }
621
622 #[test]
623 fn noise_offset_zero_without_hq() {
624 let params = NlmParams::default();
625 assert_eq!(params.noise_offset(), 0.0);
626 }
627
628 #[test]
629 fn h2_inv_norm_with_auto_strength_matches_hand_computed() {
630 let sigma = 8.0 / 255.0;
631 let params = NlmParams {
632 strength: 1.0,
633 hq: Some(HqParams::with_sigma(sigma)),
634 ..NlmParams::default()
635 };
636
637 let s_size = (2 * params.patch_radius + 1) * (2 * params.patch_radius + 1);
638 let effective_strength = 1.0 * sigma * 255.0;
639 let expected = NLM_NORM / (NLM_LEGACY * effective_strength * effective_strength * s_size as f32);
640
641 assert!(
642 (params.h2_inv_norm() - expected).abs() < 1e-6,
643 "expected {expected}, got {}",
644 params.h2_inv_norm()
645 );
646 }
647
648 #[test]
649 fn validate_rejects_zero_hq_sigma() {
650 let params = NlmParams {
651 hq: Some(HqParams::with_sigma(0.0)),
652 ..NlmParams::default()
653 };
654 assert!(params.validate().is_err());
655 }
656
657 #[test]
658 fn validate_rejects_hq_sigma_above_one() {
659 let params = NlmParams {
660 hq: Some(HqParams::with_sigma(1.5)),
661 ..NlmParams::default()
662 };
663 assert!(params.validate().is_err());
664 }
665
666 #[test]
667 fn validate_rejects_nan_hq_sigma() {
668 let params = NlmParams {
669 hq: Some(HqParams::with_sigma(f32::NAN)),
670 ..NlmParams::default()
671 };
672 assert!(params.validate().is_err());
673 }
674
675 #[test]
676 fn validate_rejects_zero_thsad_scale() {
677 let params = NlmParams {
678 hq: Some(HqParams {
679 thsad_scale: 0.0,
680 ..HqParams::default()
681 }),
682 ..NlmParams::default()
683 };
684 assert!(params.validate().is_err());
685 }
686
687 #[test]
688 fn validate_rejects_negative_thsad_scale() {
689 let params = NlmParams {
690 hq: Some(HqParams {
691 thsad_scale: -1.0,
692 ..HqParams::default()
693 }),
694 ..NlmParams::default()
695 };
696 assert!(params.validate().is_err());
697 }
698
699 #[test]
700 fn validate_rejects_nan_thsad_scale() {
701 let params = NlmParams {
702 hq: Some(HqParams {
703 thsad_scale: f32::NAN,
704 ..HqParams::default()
705 }),
706 ..NlmParams::default()
707 };
708 assert!(params.validate().is_err());
709 }
710
711 #[test]
712 fn validate_accepts_default_thsad_scale() {
713 let params = NlmParams {
714 hq: Some(HqParams::default()),
715 ..NlmParams::default()
716 };
717 assert!(params.validate().is_ok());
718 }
719
720 #[test]
721 fn hq_params_default_sigma_scale_is_one() {
722 assert_eq!(HqParams::default().sigma_scale, 1.0);
723 }
724
725 #[test]
726 fn validate_rejects_sigma_scale_below_the_minimum() {
727 let params = NlmParams {
728 hq: Some(HqParams {
729 sigma_scale: 0.05,
730 ..HqParams::default()
731 }),
732 ..NlmParams::default()
733 };
734 let err = params.validate().expect_err("0.05 is below the 0.1 minimum");
735 assert!(
736 err.to_string().contains("hq sigma_scale"),
737 "error should name the field, got {err}"
738 );
739 }
740
741 #[test]
742 fn validate_rejects_sigma_scale_above_the_maximum() {
743 let params = NlmParams {
744 hq: Some(HqParams {
745 sigma_scale: 10.5,
746 ..HqParams::default()
747 }),
748 ..NlmParams::default()
749 };
750 assert!(params.validate().is_err());
751 }
752
753 #[test]
754 fn validate_rejects_nan_sigma_scale() {
755 let params = NlmParams {
756 hq: Some(HqParams {
757 sigma_scale: f32::NAN,
758 ..HqParams::default()
759 }),
760 ..NlmParams::default()
761 };
762 assert!(params.validate().is_err());
763 }
764
765 #[test]
766 fn validate_accepts_sigma_scale_at_the_bounds() {
767 let low = NlmParams {
768 hq: Some(HqParams {
769 sigma_scale: 0.1,
770 ..HqParams::default()
771 }),
772 ..NlmParams::default()
773 };
774 assert!(low.validate().is_ok());
775
776 let high = NlmParams {
777 hq: Some(HqParams {
778 sigma_scale: 10.0,
779 ..HqParams::default()
780 }),
781 ..NlmParams::default()
782 };
783 assert!(high.validate().is_ok());
784 }
785
786 #[test]
787 fn noise_offset_with_handles_distinct_per_channel_sigmas() {
788 let sigma_u = 4.0 / 255.0;
789 let sigma_v = 10.0 / 255.0;
790 let params = NlmParams {
791 patch_radius: 4,
792 channels: ChannelMode::Chroma,
793 hq: Some(HqParams {
794 auto_strength: true,
795 noise_floor: true,
796 sigma_override: None,
797 temporal_confidence: true,
798 thsad_scale: 1.0,
799 sigma_scale: 1.0,
800 }),
801 ..NlmParams::default()
802 };
803
804 let s_size = (2 * params.patch_radius + 1) * (2 * params.patch_radius + 1);
805 let expected = 2.0 * 1.5 * (sigma_u * sigma_u + sigma_v * sigma_v) * s_size as f32;
808
809 let got = params.noise_offset_with(Some(&[sigma_u, sigma_v]));
810 assert!((got - expected).abs() < 1e-9, "expected {expected}, got {got}");
811 }
812
813 #[test]
814 fn sigma_eff_is_rms_over_active_channels() {
815 let sigmas = [3.0 / 255.0, 4.0 / 255.0];
816 let got = sigma_eff(&sigmas, ChannelMode::Chroma);
817 let expected = ((sigmas[0] * sigmas[0] + sigmas[1] * sigmas[1]) / 2.0).sqrt();
818 assert!((got - expected).abs() < 1e-9, "expected {expected}, got {got}");
819 }
820
821 #[test]
822 fn validate_rejects_non_positive_pilot_strength_scale() {
823 let zero = NlmParams {
824 prefilter: PrefilterMode::NlmSpatial { strength_scale: 0.0 },
825 ..NlmParams::default()
826 };
827 assert!(zero.validate().is_err());
828
829 let nan = NlmParams {
830 prefilter: PrefilterMode::NlmSpatial {
831 strength_scale: f32::NAN,
832 },
833 ..NlmParams::default()
834 };
835 assert!(nan.validate().is_err());
836 }
837
838 #[test]
839 fn validate_rejects_pilot_with_patch_radius_above_separable_threshold() {
840 let params = NlmParams {
841 prefilter: PrefilterMode::NlmSpatial { strength_scale: 1.0 },
842 patch_radius: SEPARABLE_THRESHOLD + 1,
843 ..NlmParams::default()
844 };
845 assert!(params.validate().is_err());
846 }
847
848 #[test]
849 fn validate_accepts_pilot_within_limits() {
850 let params = NlmParams {
851 prefilter: PrefilterMode::NlmSpatial { strength_scale: 1.0 },
852 patch_radius: SEPARABLE_THRESHOLD,
853 ..NlmParams::default()
854 };
855 assert!(params.validate().is_ok());
856 }
857
858 #[test]
859 fn validate_rejects_non_positive_bilateral_sigma_r() {
860 let params = NlmParams {
864 prefilter: PrefilterMode::Bilateral {
865 sigma_s: 3.0,
866 sigma_r: 0.0,
867 },
868 ..NlmParams::default()
869 };
870 assert!(params.validate().is_err());
871
872 let negative = NlmParams {
873 prefilter: PrefilterMode::Bilateral {
874 sigma_s: 3.0,
875 sigma_r: -0.02,
876 },
877 ..NlmParams::default()
878 };
879 assert!(negative.validate().is_err());
880
881 let nan = NlmParams {
882 prefilter: PrefilterMode::Bilateral {
883 sigma_s: 3.0,
884 sigma_r: f32::NAN,
885 },
886 ..NlmParams::default()
887 };
888 assert!(nan.validate().is_err());
889
890 let inf = NlmParams {
891 prefilter: PrefilterMode::Bilateral {
892 sigma_s: 3.0,
893 sigma_r: f32::INFINITY,
894 },
895 ..NlmParams::default()
896 };
897 assert!(inf.validate().is_err());
898 }
899
900 #[test]
901 fn validate_rejects_non_positive_bilateral_sigma_s() {
902 let params = NlmParams {
906 prefilter: PrefilterMode::Bilateral {
907 sigma_s: 0.0,
908 sigma_r: 0.02,
909 },
910 ..NlmParams::default()
911 };
912 assert!(params.validate().is_err());
913
914 let negative = NlmParams {
915 prefilter: PrefilterMode::Bilateral {
916 sigma_s: -3.0,
917 sigma_r: 0.02,
918 },
919 ..NlmParams::default()
920 };
921 assert!(negative.validate().is_err());
922
923 let nan = NlmParams {
924 prefilter: PrefilterMode::Bilateral {
925 sigma_s: f32::NAN,
926 sigma_r: 0.02,
927 },
928 ..NlmParams::default()
929 };
930 assert!(nan.validate().is_err());
931
932 let inf = NlmParams {
933 prefilter: PrefilterMode::Bilateral {
934 sigma_s: f32::INFINITY,
935 sigma_r: 0.02,
936 },
937 ..NlmParams::default()
938 };
939 assert!(inf.validate().is_err());
940 }
941
942 #[test]
943 fn validate_accepts_positive_finite_bilateral_sigmas() {
944 let params = NlmParams {
945 prefilter: PrefilterMode::Bilateral {
946 sigma_s: 3.0,
947 sigma_r: 0.02,
948 },
949 ..NlmParams::default()
950 };
951 assert!(params.validate().is_ok());
952 }
953
954 #[test]
955 fn validate_accepts_a_small_positive_bilateral_sigma_at_the_boundary() {
956 let safe_small = 1e-6_f32;
964 assert!(
965 (safe_small * safe_small).is_normal(),
966 "the test value itself must not underflow"
967 );
968
969 let small_sigma_s = NlmParams {
970 prefilter: PrefilterMode::Bilateral {
971 sigma_s: safe_small,
972 sigma_r: 0.02,
973 },
974 ..NlmParams::default()
975 };
976 assert!(small_sigma_s.validate().is_ok());
977
978 let small_sigma_r = NlmParams {
979 prefilter: PrefilterMode::Bilateral {
980 sigma_s: 3.0,
981 sigma_r: safe_small,
982 },
983 ..NlmParams::default()
984 };
985 assert!(small_sigma_r.validate().is_ok());
986 }
987
988 #[test]
989 fn validate_rejects_a_subnormal_bilateral_sigma_that_underflows_on_squaring() {
990 let sq = f32::MIN_POSITIVE * f32::MIN_POSITIVE;
1002 assert_eq!(sq, 0.0, "this test assumes MIN_POSITIVE underflows on squaring");
1003 let inv = prefilter::inv_two_sigma_sq(f32::MIN_POSITIVE);
1004 assert!(
1005 !inv.is_finite(),
1006 "this test assumes the derived factor is infinite here"
1007 );
1008
1009 let sigma_s = NlmParams {
1010 prefilter: PrefilterMode::Bilateral {
1011 sigma_s: f32::MIN_POSITIVE,
1012 sigma_r: 0.02,
1013 },
1014 ..NlmParams::default()
1015 };
1016 assert!(
1017 sigma_s.validate().is_err(),
1018 "a subnormal sigma_s that underflows to an infinite normalisation factor must be rejected"
1019 );
1020
1021 let sigma_r = NlmParams {
1022 prefilter: PrefilterMode::Bilateral {
1023 sigma_s: 3.0,
1024 sigma_r: f32::MIN_POSITIVE,
1025 },
1026 ..NlmParams::default()
1027 };
1028 assert!(
1029 sigma_r.validate().is_err(),
1030 "a subnormal sigma_r that underflows to an infinite normalisation factor must be rejected"
1031 );
1032 }
1033
1034 #[test]
1037 fn validate_rejects_bilateral_sigma_s_above_the_smem_ceiling() {
1038 let params = NlmParams {
1039 prefilter: PrefilterMode::Bilateral {
1040 sigma_s: 16.0,
1041 sigma_r: 0.02,
1042 },
1043 ..NlmParams::default()
1044 };
1045 let err = params.validate().expect_err("radius 32 exceeds the 22 ceiling");
1046 assert!(
1047 err.to_string().contains("sigma_s"),
1048 "error should name the field, got {err}"
1049 );
1050 }
1051
1052 #[test]
1058 fn validate_rejects_extreme_bilateral_sigma_s() {
1059 let params = NlmParams {
1060 prefilter: PrefilterMode::Bilateral {
1061 sigma_s: 1e9,
1062 sigma_r: 0.02,
1063 },
1064 ..NlmParams::default()
1065 };
1066 assert!(params.validate().is_err());
1067 }
1068
1069 #[test]
1076 fn validate_accepts_bilateral_sigma_s_at_the_smem_ceiling() {
1077 let params = NlmParams {
1078 prefilter: PrefilterMode::Bilateral {
1079 sigma_s: 11.0,
1080 sigma_r: 0.02,
1081 },
1082 ..NlmParams::default()
1083 };
1084 assert!(params.validate().is_ok());
1085 }
1086
1087 #[test]
1088 fn validate_rejects_bilateral_sigma_s_just_above_the_smem_ceiling() {
1089 let params = NlmParams {
1090 prefilter: PrefilterMode::Bilateral {
1091 sigma_s: 11.01,
1092 sigma_r: 0.02,
1093 },
1094 ..NlmParams::default()
1095 };
1096 assert!(params.validate().is_err());
1097 }
1098
1099 #[test]
1100 fn sigma_eff_ignores_channels_past_the_mode_count() {
1101 let sigmas = [6.0 / 255.0, 100.0 / 255.0, 200.0 / 255.0];
1104 let got = sigma_eff(&sigmas, ChannelMode::Luma);
1105 assert!(
1106 (got - sigmas[0]).abs() < 1e-9,
1107 "expected {}, got {got}",
1108 sigmas[0]
1109 );
1110 }
1111
1112 #[test]
1113 fn hq_default_strength_matches_the_measured_luma_table() {
1114 const EXPECTED: [f32; 9] = [0.45, 0.45, 0.42, 0.42, 0.35, 0.35, 0.35, 0.30, 0.30];
1115 for (radius, &expected) in EXPECTED.iter().enumerate() {
1116 let got = hq_default_strength(ChannelMode::Luma, radius as u32);
1117 assert!(
1118 (got - expected).abs() < f32::EPSILON,
1119 "at radius {radius} expected {expected}, got {got}"
1120 );
1121 }
1122 }
1123
1124 #[test]
1125 fn hq_default_strength_matches_the_measured_chroma_table() {
1126 const EXPECTED: [f32; 9] = [1.00, 0.85, 0.70, 0.70, 0.70, 0.70, 0.70, 0.70, 0.70];
1127 for (radius, &expected) in EXPECTED.iter().enumerate() {
1128 let got = hq_default_strength(ChannelMode::Chroma, radius as u32);
1129 assert!(
1130 (got - expected).abs() < f32::EPSILON,
1131 "at radius {radius} expected {expected}, got {got}"
1132 );
1133 }
1134 }
1135
1136 #[test]
1137 fn hq_default_strength_yuv_reads_the_luma_table() {
1138 for radius in 0..=8u32 {
1139 let yuv = hq_default_strength(ChannelMode::Yuv, radius);
1140 let luma = hq_default_strength(ChannelMode::Luma, radius);
1141 assert!(
1142 (yuv - luma).abs() < f32::EPSILON,
1143 "at radius {radius} yuv is {yuv} but luma is {luma}"
1144 );
1145 }
1146 }
1147
1148 #[test]
1149 fn validate_dimensions_rejects_frames_below_the_minimum() {
1150 assert!(validate_dimensions(2, 64).is_err());
1151 assert!(validate_dimensions(64, 2).is_err());
1152 assert!(validate_dimensions(0, 0).is_err());
1153 }
1154
1155 #[test]
1156 fn validate_dimensions_accepts_the_minimum() {
1157 assert!(validate_dimensions(MIN_FRAME_DIM, MIN_FRAME_DIM).is_ok());
1158 assert!(validate_dimensions(1920, 1080).is_ok());
1159 }
1160
1161 #[test]
1162 fn hq_default_strength_clamps_radius_above_the_table() {
1163 let at_max = hq_default_strength(ChannelMode::Luma, MAX_TEMPORAL_RADIUS);
1164 let above_max = hq_default_strength(ChannelMode::Luma, MAX_TEMPORAL_RADIUS + 5);
1165 assert!(
1166 (at_max - above_max).abs() < f32::EPSILON,
1167 "expected clamping to hold the last table entry, got {at_max} vs {above_max}"
1168 );
1169 }
1170}