1use super::{MotionCompensationMode, PrefilterMode, prefilter};
2
3pub(super) const NLM_NORM: f32 = 255.0 * 255.0;
7pub(super) const NLM_LEGACY: f32 = 3.0;
10
11const HQ_DEFAULT_STRENGTH_LUMA: [f32; 9] = [0.45, 0.45, 0.42, 0.42, 0.35, 0.35, 0.35, 0.30, 0.30];
15
16const HQ_DEFAULT_STRENGTH_CHROMA: [f32; 9] = [1.00, 0.85, 0.70, 0.70, 0.70, 0.70, 0.70, 0.70, 0.70];
20
21pub fn hq_default_strength(channels: ChannelMode, temporal_radius: u32) -> f32 {
59 let idx = temporal_radius.min(MAX_TEMPORAL_RADIUS) as usize;
60 match channels {
61 ChannelMode::Luma | ChannelMode::Yuv => HQ_DEFAULT_STRENGTH_LUMA[idx],
62 ChannelMode::Chroma => HQ_DEFAULT_STRENGTH_CHROMA[idx],
63 }
64}
65
66pub const MIN_FRAME_DIM: u32 = 3;
71
72pub fn validate_dimensions(width: u32, height: u32) -> Result<(), anyhow::Error> {
75 if width < MIN_FRAME_DIM || height < MIN_FRAME_DIM {
76 anyhow::bail!(
77 "frame dimensions {width}x{height} are below the supported minimum \
78 ({MIN_FRAME_DIM}x{MIN_FRAME_DIM}); the noise estimate needs at least \
79 one interior pixel"
80 );
81 }
82 Ok(())
83}
84
85pub(super) const SEPARABLE_THRESHOLD: u32 = 8;
88
89pub const MAX_PATCH_RADIUS: u32 = 16;
93
94pub const MAX_SEARCH_RADIUS: u32 = 8;
105
106pub const MAX_TEMPORAL_RADIUS: u32 = 8;
110
111pub const MAX_BILATERAL_RADIUS: u32 = 22;
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum ChannelMode {
129 Luma,
131 Chroma,
133 Yuv,
135}
136
137impl ChannelMode {
138 pub fn count(self) -> u32 {
140 match self {
141 ChannelMode::Luma => 1,
142 ChannelMode::Chroma => 2,
143 ChannelMode::Yuv => 3,
144 }
145 }
146
147 pub fn storage_count(self) -> u32 {
151 match self {
152 ChannelMode::Luma => 1,
153 ChannelMode::Chroma => 2,
154 ChannelMode::Yuv => 4,
155 }
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq)]
163pub struct HqParams {
164 pub auto_strength: bool,
168 pub noise_floor: bool,
172 pub sigma_override: Option<f32>,
178 pub temporal_confidence: bool,
183 pub thsad_scale: f32,
187 pub sigma_scale: f32,
193}
194
195impl Default for HqParams {
196 fn default() -> Self {
197 Self {
198 auto_strength: true,
199 noise_floor: true,
200 sigma_override: None,
201 temporal_confidence: true,
202 thsad_scale: 1.0,
203 sigma_scale: 1.0,
204 }
205 }
206}
207
208impl HqParams {
209 pub fn with_sigma(sigma: f32) -> Self {
212 Self {
213 sigma_override: Some(sigma),
214 ..Self::default()
215 }
216 }
217}
218
219#[derive(Debug, Clone)]
220pub struct NlmParams {
221 pub temporal_radius: u32,
223 pub search_radius: u32,
225 pub patch_radius: u32,
227 pub strength: f32,
229 pub self_weight: f32,
231 pub channels: ChannelMode,
233 pub prefilter: PrefilterMode,
238 pub motion_compensation: MotionCompensationMode,
243 pub hq: Option<HqParams>,
245}
246
247impl Default for NlmParams {
248 fn default() -> Self {
249 Self {
250 temporal_radius: 0,
251 search_radius: 2,
252 patch_radius: 4,
253 strength: 1.2,
254 self_weight: 1.0,
255 channels: ChannelMode::Yuv,
256 prefilter: PrefilterMode::None,
257 motion_compensation: MotionCompensationMode::None,
258 hq: None,
259 }
260 }
261}
262
263impl NlmParams {
264 pub(super) fn effective_strength_with(&self, sigma_eff: Option<f32>) -> f32 {
269 match (self.hq, sigma_eff) {
270 (Some(hq), Some(sigma)) if hq.auto_strength => self.strength * sigma * 255.0,
271 _ => self.strength,
272 }
273 }
274
275 pub fn h2_inv_norm_with(&self, sigma_eff: Option<f32>) -> f32 {
279 let s_size = (2 * self.patch_radius + 1) * (2 * self.patch_radius + 1);
280 let s = self.effective_strength_with(sigma_eff);
281 NLM_NORM / (NLM_LEGACY * s * s * s_size as f32)
282 }
283
284 pub fn h2_inv_norm(&self) -> f32 {
289 self.h2_inv_norm_with(self.hq.and_then(|hq| hq.sigma_override))
290 }
291
292 pub(super) fn noise_offset_with(&self, sigmas: Option<&[f32]>) -> f32 {
298 match (self.hq, sigmas) {
299 (Some(hq), Some(sigmas)) if hq.noise_floor => {
300 let s_size = (2 * self.patch_radius + 1) * (2 * self.patch_radius + 1);
301 let scale = channel_scale(self.channels);
302 let count = self.channels.count() as usize;
303 let sum_sq: f32 = sigmas.iter().take(count).map(|&s| s * s).sum();
304 2.0 * scale * sum_sq * s_size as f32
305 },
306 _ => 0.0,
307 }
308 }
309
310 pub(super) fn noise_offset(&self) -> f32 {
315 match self.hq.and_then(|hq| hq.sigma_override) {
316 Some(sigma) => {
317 let sigmas = [sigma; 3];
318 self.noise_offset_with(Some(&sigmas[..self.channels.count() as usize]))
319 },
320 None => 0.0,
321 }
322 }
323
324 pub(super) fn total_frames(&self) -> u32 {
325 1 + 2 * self.temporal_radius
326 }
327
328 pub fn validate(&self) -> Result<(), anyhow::Error> {
334 if self.patch_radius > MAX_PATCH_RADIUS {
335 anyhow::bail!(
336 "patch_radius={} exceeds the supported maximum ({}); larger patches \
337 exhaust on-chip SMEM in the fused/windowed kernels",
338 self.patch_radius,
339 MAX_PATCH_RADIUS,
340 );
341 }
342
343 if self.search_radius > MAX_SEARCH_RADIUS {
344 anyhow::bail!(
345 "search_radius={} exceeds the supported maximum ({}). The windowed \
346 kernel's search window loop is fully unrolled, so its compiled size \
347 and codegen time both grow with search_radius",
348 self.search_radius,
349 MAX_SEARCH_RADIUS,
350 );
351 }
352
353 if self.temporal_radius > MAX_TEMPORAL_RADIUS {
354 anyhow::bail!(
355 "temporal_radius={} exceeds the supported maximum ({}); the ring \
356 buffer grows linearly with the window size",
357 self.temporal_radius,
358 MAX_TEMPORAL_RADIUS,
359 );
360 }
361
362 if !(self.strength.is_finite() && self.strength > 0.0) {
363 anyhow::bail!(
364 "strength must be finite and > 0 (got {}); strength = 0 produces an \
365 infinite Welsch normalisation factor",
366 self.strength,
367 );
368 }
369
370 if !self.self_weight.is_finite() || self.self_weight < 0.0 {
371 anyhow::bail!("self_weight must be finite and >= 0 (got {})", self.self_weight,);
372 }
373
374 if let Some(hq) = self.hq
375 && let Some(sigma) = hq.sigma_override
376 && (!sigma.is_finite() || sigma <= 0.0 || sigma > 1.0)
377 {
378 anyhow::bail!(
379 "hq sigma_override must be finite and in (0, 1] in normalised units (got {})",
380 sigma,
381 );
382 }
383
384 if let Some(hq) = self.hq
385 && !(hq.thsad_scale.is_finite() && hq.thsad_scale > 0.0)
386 {
387 anyhow::bail!(
388 "hq thsad_scale must be finite and > 0 (got {}); thsad_scale = 0 collapses \
389 every block's confidence to zero regardless of match quality",
390 hq.thsad_scale,
391 );
392 }
393
394 if let Some(hq) = self.hq
395 && !(hq.sigma_scale.is_finite() && (0.1..=10.0).contains(&hq.sigma_scale))
396 {
397 anyhow::bail!(
398 "hq sigma_scale must be finite and in [0.1, 10.0] (got {})",
399 hq.sigma_scale,
400 );
401 }
402
403 if let PrefilterMode::Bilateral { sigma_s, sigma_r } = self.prefilter {
404 if !sigma_s.is_finite() || sigma_s <= 0.0 {
405 anyhow::bail!(
406 "bilateral prefilter sigma_s must be finite and > 0 (got {}); \
407 sigma_s = 0 produces an infinite spatial-weight normalisation factor",
408 sigma_s,
409 );
410 }
411 if !sigma_r.is_finite() || sigma_r <= 0.0 {
412 anyhow::bail!(
413 "bilateral prefilter sigma_r must be finite and > 0 (got {}); \
414 sigma_r = 0 produces an infinite range-weight normalisation factor \
415 that turns the centre tap into NaN",
416 sigma_r,
417 );
418 }
419 let bilateral_radius = prefilter::bilateral_radius(sigma_s);
428 if bilateral_radius > MAX_BILATERAL_RADIUS {
429 anyhow::bail!(
430 "bilateral prefilter sigma_s={} implies a shared-memory tile radius of {} \
431 (radius = ceil(2 * sigma_s), minimum 1), exceeding the supported maximum \
432 ({}); larger radii exhaust on-chip SMEM in the bilateral kernel",
433 sigma_s,
434 bilateral_radius,
435 MAX_BILATERAL_RADIUS,
436 );
437 }
438 if !prefilter::inv_two_sigma_sq(sigma_s).is_finite() {
448 anyhow::bail!(
449 "bilateral prefilter sigma_s is too small (got {}); sigma_s * sigma_s \
450 underflows to 0 in f32, making the spatial-weight normalisation factor \
451 infinite",
452 sigma_s,
453 );
454 }
455 if !prefilter::inv_two_sigma_sq(sigma_r).is_finite() {
456 anyhow::bail!(
457 "bilateral prefilter sigma_r is too small (got {}); sigma_r * sigma_r \
458 underflows to 0 in f32, making the range-weight normalisation factor \
459 infinite and the centre tap NaN",
460 sigma_r,
461 );
462 }
463 }
464
465 if let PrefilterMode::NlmSpatial { strength_scale } = self.prefilter {
466 if !strength_scale.is_finite() || strength_scale <= 0.0 {
467 anyhow::bail!(
468 "nlm pilot strength_scale must be finite and > 0 (got {})",
469 strength_scale,
470 );
471 }
472 if self.patch_radius > SEPARABLE_THRESHOLD {
473 anyhow::bail!(
474 "the nlm pilot uses the windowed spatial kernel, which supports \
475 patch_radius up to {} (got {})",
476 SEPARABLE_THRESHOLD,
477 self.patch_radius,
478 );
479 }
480 }
481
482 self.motion_compensation.validate()?;
483
484 Ok(())
485 }
486}
487
488pub(super) fn channel_scale(channels: ChannelMode) -> f32 {
492 match channels {
493 ChannelMode::Luma => 3.0,
494 ChannelMode::Chroma => 1.5,
495 ChannelMode::Yuv => 1.0,
496 }
497}
498
499pub(super) fn sigma_eff(sigmas: &[f32], channels: ChannelMode) -> f32 {
505 let count = channels.count() as usize;
506 let sum_sq: f32 = sigmas.iter().take(count).map(|&s| s * s).sum();
507 (sum_sq / count as f32).sqrt()
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn noise_offset_scales_with_sigma_and_patch_size() {
516 let sigma = 4.0 / 255.0;
517 let params = NlmParams {
518 patch_radius: 4,
519 hq: Some(HqParams::with_sigma(sigma)),
520 ..NlmParams::default()
521 };
522
523 let expected = 6.0 * sigma * sigma * 81.0;
524 assert!(
525 (params.noise_offset() - expected).abs() < 1e-6,
526 "expected {expected}, got {}",
527 params.noise_offset()
528 );
529 }
530
531 #[test]
532 fn noise_offset_zero_without_noise_floor() {
533 let params = NlmParams {
534 hq: Some(HqParams {
535 auto_strength: true,
536 noise_floor: false,
537 sigma_override: Some(4.0 / 255.0),
538 temporal_confidence: true,
539 thsad_scale: 1.0,
540 sigma_scale: 1.0,
541 }),
542 ..NlmParams::default()
543 };
544
545 assert_eq!(params.noise_offset(), 0.0);
546 }
547
548 #[test]
549 fn noise_offset_zero_without_hq() {
550 let params = NlmParams::default();
551 assert_eq!(params.noise_offset(), 0.0);
552 }
553
554 #[test]
555 fn h2_inv_norm_with_auto_strength_matches_hand_computed() {
556 let sigma = 8.0 / 255.0;
557 let params = NlmParams {
558 strength: 1.0,
559 hq: Some(HqParams::with_sigma(sigma)),
560 ..NlmParams::default()
561 };
562
563 let s_size = (2 * params.patch_radius + 1) * (2 * params.patch_radius + 1);
564 let effective_strength = 1.0 * sigma * 255.0;
565 let expected = NLM_NORM / (NLM_LEGACY * effective_strength * effective_strength * s_size as f32);
566
567 assert!(
568 (params.h2_inv_norm() - expected).abs() < 1e-6,
569 "expected {expected}, got {}",
570 params.h2_inv_norm()
571 );
572 }
573
574 #[test]
575 fn validate_rejects_zero_hq_sigma() {
576 let params = NlmParams {
577 hq: Some(HqParams::with_sigma(0.0)),
578 ..NlmParams::default()
579 };
580 assert!(params.validate().is_err());
581 }
582
583 #[test]
584 fn validate_rejects_hq_sigma_above_one() {
585 let params = NlmParams {
586 hq: Some(HqParams::with_sigma(1.5)),
587 ..NlmParams::default()
588 };
589 assert!(params.validate().is_err());
590 }
591
592 #[test]
593 fn validate_rejects_nan_hq_sigma() {
594 let params = NlmParams {
595 hq: Some(HqParams::with_sigma(f32::NAN)),
596 ..NlmParams::default()
597 };
598 assert!(params.validate().is_err());
599 }
600
601 #[test]
602 fn validate_rejects_zero_thsad_scale() {
603 let params = NlmParams {
604 hq: Some(HqParams {
605 thsad_scale: 0.0,
606 ..HqParams::default()
607 }),
608 ..NlmParams::default()
609 };
610 assert!(params.validate().is_err());
611 }
612
613 #[test]
614 fn validate_rejects_negative_thsad_scale() {
615 let params = NlmParams {
616 hq: Some(HqParams {
617 thsad_scale: -1.0,
618 ..HqParams::default()
619 }),
620 ..NlmParams::default()
621 };
622 assert!(params.validate().is_err());
623 }
624
625 #[test]
626 fn validate_rejects_nan_thsad_scale() {
627 let params = NlmParams {
628 hq: Some(HqParams {
629 thsad_scale: f32::NAN,
630 ..HqParams::default()
631 }),
632 ..NlmParams::default()
633 };
634 assert!(params.validate().is_err());
635 }
636
637 #[test]
638 fn validate_accepts_default_thsad_scale() {
639 let params = NlmParams {
640 hq: Some(HqParams::default()),
641 ..NlmParams::default()
642 };
643 assert!(params.validate().is_ok());
644 }
645
646 #[test]
647 fn hq_params_default_sigma_scale_is_one() {
648 assert_eq!(HqParams::default().sigma_scale, 1.0);
649 }
650
651 #[test]
652 fn validate_rejects_sigma_scale_below_the_minimum() {
653 let params = NlmParams {
654 hq: Some(HqParams {
655 sigma_scale: 0.05,
656 ..HqParams::default()
657 }),
658 ..NlmParams::default()
659 };
660 let err = params.validate().expect_err("0.05 is below the 0.1 minimum");
661 assert!(
662 err.to_string().contains("hq sigma_scale"),
663 "error should name the field, got: {err}"
664 );
665 }
666
667 #[test]
668 fn validate_rejects_sigma_scale_above_the_maximum() {
669 let params = NlmParams {
670 hq: Some(HqParams {
671 sigma_scale: 10.5,
672 ..HqParams::default()
673 }),
674 ..NlmParams::default()
675 };
676 assert!(params.validate().is_err());
677 }
678
679 #[test]
680 fn validate_rejects_nan_sigma_scale() {
681 let params = NlmParams {
682 hq: Some(HqParams {
683 sigma_scale: f32::NAN,
684 ..HqParams::default()
685 }),
686 ..NlmParams::default()
687 };
688 assert!(params.validate().is_err());
689 }
690
691 #[test]
692 fn validate_accepts_sigma_scale_at_the_bounds() {
693 let low = NlmParams {
694 hq: Some(HqParams {
695 sigma_scale: 0.1,
696 ..HqParams::default()
697 }),
698 ..NlmParams::default()
699 };
700 assert!(low.validate().is_ok());
701
702 let high = NlmParams {
703 hq: Some(HqParams {
704 sigma_scale: 10.0,
705 ..HqParams::default()
706 }),
707 ..NlmParams::default()
708 };
709 assert!(high.validate().is_ok());
710 }
711
712 #[test]
713 fn noise_offset_with_handles_distinct_per_channel_sigmas() {
714 let sigma_u = 4.0 / 255.0;
715 let sigma_v = 10.0 / 255.0;
716 let params = NlmParams {
717 patch_radius: 4,
718 channels: ChannelMode::Chroma,
719 hq: Some(HqParams {
720 auto_strength: true,
721 noise_floor: true,
722 sigma_override: None,
723 temporal_confidence: true,
724 thsad_scale: 1.0,
725 sigma_scale: 1.0,
726 }),
727 ..NlmParams::default()
728 };
729
730 let s_size = (2 * params.patch_radius + 1) * (2 * params.patch_radius + 1);
731 let expected = 2.0 * 1.5 * (sigma_u * sigma_u + sigma_v * sigma_v) * s_size as f32;
734
735 let got = params.noise_offset_with(Some(&[sigma_u, sigma_v]));
736 assert!((got - expected).abs() < 1e-9, "expected {expected}, got {got}");
737 }
738
739 #[test]
740 fn sigma_eff_is_rms_over_active_channels() {
741 let sigmas = [3.0 / 255.0, 4.0 / 255.0];
742 let got = sigma_eff(&sigmas, ChannelMode::Chroma);
743 let expected = ((sigmas[0] * sigmas[0] + sigmas[1] * sigmas[1]) / 2.0).sqrt();
744 assert!((got - expected).abs() < 1e-9, "expected {expected}, got {got}");
745 }
746
747 #[test]
748 fn validate_rejects_non_positive_pilot_strength_scale() {
749 let zero = NlmParams {
750 prefilter: PrefilterMode::NlmSpatial { strength_scale: 0.0 },
751 ..NlmParams::default()
752 };
753 assert!(zero.validate().is_err());
754
755 let nan = NlmParams {
756 prefilter: PrefilterMode::NlmSpatial {
757 strength_scale: f32::NAN,
758 },
759 ..NlmParams::default()
760 };
761 assert!(nan.validate().is_err());
762 }
763
764 #[test]
765 fn validate_rejects_pilot_with_patch_radius_above_separable_threshold() {
766 let params = NlmParams {
767 prefilter: PrefilterMode::NlmSpatial { strength_scale: 1.0 },
768 patch_radius: SEPARABLE_THRESHOLD + 1,
769 ..NlmParams::default()
770 };
771 assert!(params.validate().is_err());
772 }
773
774 #[test]
775 fn validate_accepts_pilot_within_limits() {
776 let params = NlmParams {
777 prefilter: PrefilterMode::NlmSpatial { strength_scale: 1.0 },
778 patch_radius: SEPARABLE_THRESHOLD,
779 ..NlmParams::default()
780 };
781 assert!(params.validate().is_ok());
782 }
783
784 #[test]
785 fn validate_rejects_non_positive_bilateral_sigma_r() {
786 let params = NlmParams {
790 prefilter: PrefilterMode::Bilateral {
791 sigma_s: 3.0,
792 sigma_r: 0.0,
793 },
794 ..NlmParams::default()
795 };
796 assert!(params.validate().is_err());
797
798 let negative = NlmParams {
799 prefilter: PrefilterMode::Bilateral {
800 sigma_s: 3.0,
801 sigma_r: -0.02,
802 },
803 ..NlmParams::default()
804 };
805 assert!(negative.validate().is_err());
806
807 let nan = NlmParams {
808 prefilter: PrefilterMode::Bilateral {
809 sigma_s: 3.0,
810 sigma_r: f32::NAN,
811 },
812 ..NlmParams::default()
813 };
814 assert!(nan.validate().is_err());
815
816 let inf = NlmParams {
817 prefilter: PrefilterMode::Bilateral {
818 sigma_s: 3.0,
819 sigma_r: f32::INFINITY,
820 },
821 ..NlmParams::default()
822 };
823 assert!(inf.validate().is_err());
824 }
825
826 #[test]
827 fn validate_rejects_non_positive_bilateral_sigma_s() {
828 let params = NlmParams {
832 prefilter: PrefilterMode::Bilateral {
833 sigma_s: 0.0,
834 sigma_r: 0.02,
835 },
836 ..NlmParams::default()
837 };
838 assert!(params.validate().is_err());
839
840 let negative = NlmParams {
841 prefilter: PrefilterMode::Bilateral {
842 sigma_s: -3.0,
843 sigma_r: 0.02,
844 },
845 ..NlmParams::default()
846 };
847 assert!(negative.validate().is_err());
848
849 let nan = NlmParams {
850 prefilter: PrefilterMode::Bilateral {
851 sigma_s: f32::NAN,
852 sigma_r: 0.02,
853 },
854 ..NlmParams::default()
855 };
856 assert!(nan.validate().is_err());
857
858 let inf = NlmParams {
859 prefilter: PrefilterMode::Bilateral {
860 sigma_s: f32::INFINITY,
861 sigma_r: 0.02,
862 },
863 ..NlmParams::default()
864 };
865 assert!(inf.validate().is_err());
866 }
867
868 #[test]
869 fn validate_accepts_positive_finite_bilateral_sigmas() {
870 let params = NlmParams {
871 prefilter: PrefilterMode::Bilateral {
872 sigma_s: 3.0,
873 sigma_r: 0.02,
874 },
875 ..NlmParams::default()
876 };
877 assert!(params.validate().is_ok());
878 }
879
880 #[test]
881 fn validate_accepts_a_small_positive_bilateral_sigma_at_the_boundary() {
882 let safe_small = 1e-6_f32;
890 assert!(
891 (safe_small * safe_small).is_normal(),
892 "test value itself must not underflow"
893 );
894
895 let small_sigma_s = NlmParams {
896 prefilter: PrefilterMode::Bilateral {
897 sigma_s: safe_small,
898 sigma_r: 0.02,
899 },
900 ..NlmParams::default()
901 };
902 assert!(small_sigma_s.validate().is_ok());
903
904 let small_sigma_r = NlmParams {
905 prefilter: PrefilterMode::Bilateral {
906 sigma_s: 3.0,
907 sigma_r: safe_small,
908 },
909 ..NlmParams::default()
910 };
911 assert!(small_sigma_r.validate().is_ok());
912 }
913
914 #[test]
915 fn validate_rejects_a_subnormal_bilateral_sigma_that_underflows_on_squaring() {
916 let sq = f32::MIN_POSITIVE * f32::MIN_POSITIVE;
926 assert_eq!(
927 sq, 0.0,
928 "test assumption: MIN_POSITIVE must underflow on squaring"
929 );
930 let inv = prefilter::inv_two_sigma_sq(f32::MIN_POSITIVE);
931 assert!(
932 !inv.is_finite(),
933 "test assumption: the derived factor must be infinite here"
934 );
935
936 let sigma_s = NlmParams {
937 prefilter: PrefilterMode::Bilateral {
938 sigma_s: f32::MIN_POSITIVE,
939 sigma_r: 0.02,
940 },
941 ..NlmParams::default()
942 };
943 assert!(
944 sigma_s.validate().is_err(),
945 "a subnormal sigma_s that underflows to an infinite normalisation factor must be rejected"
946 );
947
948 let sigma_r = NlmParams {
949 prefilter: PrefilterMode::Bilateral {
950 sigma_s: 3.0,
951 sigma_r: f32::MIN_POSITIVE,
952 },
953 ..NlmParams::default()
954 };
955 assert!(
956 sigma_r.validate().is_err(),
957 "a subnormal sigma_r that underflows to an infinite normalisation factor must be rejected"
958 );
959 }
960
961 #[test]
964 fn validate_rejects_bilateral_sigma_s_above_the_smem_ceiling() {
965 let params = NlmParams {
966 prefilter: PrefilterMode::Bilateral {
967 sigma_s: 16.0,
968 sigma_r: 0.02,
969 },
970 ..NlmParams::default()
971 };
972 let err = params.validate().expect_err("radius 32 exceeds the 22 ceiling");
973 assert!(
974 err.to_string().contains("sigma_s"),
975 "error should name the field, got: {err}"
976 );
977 }
978
979 #[test]
984 fn validate_rejects_extreme_bilateral_sigma_s() {
985 let params = NlmParams {
986 prefilter: PrefilterMode::Bilateral {
987 sigma_s: 1e9,
988 sigma_r: 0.02,
989 },
990 ..NlmParams::default()
991 };
992 assert!(params.validate().is_err());
993 }
994
995 #[test]
1001 fn validate_accepts_bilateral_sigma_s_at_the_smem_ceiling() {
1002 let params = NlmParams {
1003 prefilter: PrefilterMode::Bilateral {
1004 sigma_s: 11.0,
1005 sigma_r: 0.02,
1006 },
1007 ..NlmParams::default()
1008 };
1009 assert!(params.validate().is_ok());
1010 }
1011
1012 #[test]
1013 fn validate_rejects_bilateral_sigma_s_just_above_the_smem_ceiling() {
1014 let params = NlmParams {
1015 prefilter: PrefilterMode::Bilateral {
1016 sigma_s: 11.01,
1017 sigma_r: 0.02,
1018 },
1019 ..NlmParams::default()
1020 };
1021 assert!(params.validate().is_err());
1022 }
1023
1024 #[test]
1025 fn sigma_eff_ignores_channels_past_the_mode_count() {
1026 let sigmas = [6.0 / 255.0, 100.0 / 255.0, 200.0 / 255.0];
1029 let got = sigma_eff(&sigmas, ChannelMode::Luma);
1030 assert!(
1031 (got - sigmas[0]).abs() < 1e-9,
1032 "expected {}, got {got}",
1033 sigmas[0]
1034 );
1035 }
1036
1037 #[test]
1038 fn hq_default_strength_matches_the_measured_luma_table() {
1039 const EXPECTED: [f32; 9] = [0.45, 0.45, 0.42, 0.42, 0.35, 0.35, 0.35, 0.30, 0.30];
1040 for (radius, &expected) in EXPECTED.iter().enumerate() {
1041 let got = hq_default_strength(ChannelMode::Luma, radius as u32);
1042 assert!(
1043 (got - expected).abs() < f32::EPSILON,
1044 "radius={radius}: expected {expected}, got {got}"
1045 );
1046 }
1047 }
1048
1049 #[test]
1050 fn hq_default_strength_matches_the_measured_chroma_table() {
1051 const EXPECTED: [f32; 9] = [1.00, 0.85, 0.70, 0.70, 0.70, 0.70, 0.70, 0.70, 0.70];
1052 for (radius, &expected) in EXPECTED.iter().enumerate() {
1053 let got = hq_default_strength(ChannelMode::Chroma, radius as u32);
1054 assert!(
1055 (got - expected).abs() < f32::EPSILON,
1056 "radius={radius}: expected {expected}, got {got}"
1057 );
1058 }
1059 }
1060
1061 #[test]
1062 fn hq_default_strength_yuv_reads_the_luma_table() {
1063 for radius in 0..=8u32 {
1064 let yuv = hq_default_strength(ChannelMode::Yuv, radius);
1065 let luma = hq_default_strength(ChannelMode::Luma, radius);
1066 assert!(
1067 (yuv - luma).abs() < f32::EPSILON,
1068 "radius={radius}: yuv={yuv}, luma={luma}"
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn validate_dimensions_rejects_frames_below_the_minimum() {
1075 assert!(validate_dimensions(2, 64).is_err());
1076 assert!(validate_dimensions(64, 2).is_err());
1077 assert!(validate_dimensions(0, 0).is_err());
1078 }
1079
1080 #[test]
1081 fn validate_dimensions_accepts_the_minimum() {
1082 assert!(validate_dimensions(MIN_FRAME_DIM, MIN_FRAME_DIM).is_ok());
1083 assert!(validate_dimensions(1920, 1080).is_ok());
1084 }
1085
1086 #[test]
1087 fn hq_default_strength_clamps_radius_above_the_table() {
1088 let at_max = hq_default_strength(ChannelMode::Luma, MAX_TEMPORAL_RADIUS);
1089 let above_max = hq_default_strength(ChannelMode::Luma, MAX_TEMPORAL_RADIUS + 5);
1090 assert!(
1091 (at_max - above_max).abs() < f32::EPSILON,
1092 "expected clamping to hold the last table entry, got {at_max} vs {above_max}"
1093 );
1094 }
1095}