Skip to main content

av_denoise/nlmeans/
params.rs

1use super::{MotionCompensationMode, PrefilterMode, prefilter};
2
3/// The reference value patch distances are normalised against, matching
4/// FFmpeg's nlmeans at 255 squared.
5///
6/// Distances here are measured in `[0, 1]` units, so this constant folds
7/// in the scale-up back to 8-bit terms.
8pub(super) const NLM_NORM: f32 = 255.0 * 255.0;
9
10/// A scaling factor inherited from FFmpeg's nlmeans, kept so our
11/// `strength` parameter means the same thing theirs does.
12pub(super) const NLM_LEGACY: f32 = 3.0;
13
14/// The measured HQ default `strength` for luma at each temporal radius,
15/// indexed by `temporal_radius.min(8)`.
16///
17/// `hq_default_strength` reads this for `ChannelMode::Luma` and
18/// `ChannelMode::Yuv`.
19const 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
21/// The measured HQ default `strength` for chroma at each temporal
22/// radius, indexed by `temporal_radius.min(8)`.
23///
24/// `hq_default_strength` reads this for `ChannelMode::Chroma`.
25const 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
27/// The calibrated default `strength` multiplier for `nlmeans-hq`'s
28/// auto-strength mode.
29///
30/// The answer depends on which plane is being denoised and how far the
31/// temporal window reaches.
32///
33/// # Where the numbers come from
34///
35/// Every entry is a measured value rather than a fitted curve. Both
36/// tables come from quality-harness sweeps that score a grid of
37/// strengths at three noise levels per radius.
38///
39/// The luma sweep covers each radius from 0 to 8 directly.
40///
41/// The chroma sweep pins luma at the value already chosen for that
42/// radius, so the chroma numbers stay clean, and covers radii 0, 1, 2,
43/// 4, and 8 with a bracketed peak at each.
44///
45/// At each measured radius the chosen value is the one whose worst XPSNR
46/// gain across the tested noise levels is highest, so it holds up at
47/// whichever noise level is hardest to serve.
48///
49/// # Shape of the tables
50///
51/// Luma never rises with radius, because a wider temporal window already
52/// gathers more samples to average over.
53///
54/// Chroma falls the same way out to radius 2 and then holds flat at
55/// 0.70. Radii 3, 5, 6, and 7 sit on that measured plateau rather than
56/// being swept directly.
57///
58/// `ChannelMode::Yuv` reads the luma table, on the assumption that a
59/// fused pass is dominated by luma. That mode was not part of the sweep,
60/// so this is an assumption rather than a measurement.
61///
62/// # Clamping
63///
64/// `temporal_radius` is clamped to the last table index, which matches
65/// [`MAX_TEMPORAL_RADIUS`]. That is only a safety net, because
66/// [`NlmParams::validate`] already rejects anything larger.
67pub 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
75/// The smallest frame side length the denoiser supports.
76///
77/// The Immerkær noise estimate only reads interior pixels, because its
78/// 3x3 mask cannot reach the one-pixel border. A frame under 3 pixels
79/// across has no interior at all, which leaves the estimate undefined.
80pub const MIN_FRAME_DIM: u32 = 3;
81
82/// Rejects frame dimensions the kernels cannot handle.
83///
84/// Both denoiser constructors call this before allocating any buffer.
85pub 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
96/// The patch radius above which the dispatcher switches to the separable
97/// path, so per-pixel cost stays linear in `patch_radius`.
98pub(super) const SEPARABLE_THRESHOLD: u32 = 8;
99
100/// The hard ceiling on `patch_radius`.
101///
102/// The fused kernels load a `(block + 2 * patch_radius)^2` tile into
103/// shared memory, and anything larger runs out of it on RDNA-class GPUs.
104pub const MAX_PATCH_RADIUS: u32 = 16;
105
106/// The hard ceiling on `search_radius`.
107///
108/// Shared memory is not the limit here. The windowed kernel's tile is
109/// `(block + 2 * patch_radius + 2 * search_radius)^2 * stored_ch * 4`
110/// bytes, which stays comfortably inside hardware limits at every
111/// supported size.
112///
113/// The real cost is the kernel's fully unrolled
114/// `(2 * search_radius + 1)^2` window loop. Both its compiled size and
115/// how long it takes to generate grow with the radius. See the
116/// stack-size note in `.cargo/config.toml`.
117///
118/// The per-offset dispatch path, used when `patch_radius` forces the
119/// separable fallback, is limited by the same constant, which keeps its
120/// `(2 * search_radius + 1)^2` launches per temporal offset reasonable.
121pub const MAX_SEARCH_RADIUS: u32 = 8;
122
123/// The hard ceiling on `temporal_radius`.
124///
125/// The ring buffer holds `2 * radius + 1` frames, so device memory grows
126/// with it. At radius 16, 1080p YUV would need roughly 540 MB for the
127/// input alone.
128pub const MAX_TEMPORAL_RADIUS: u32 = 8;
129
130/// The hard ceiling on the radius the bilateral prefilter derives from
131/// `sigma_s`, where `radius = ceil(2 * sigma_s).max(1)`. See
132/// `prefilter::bilateral_radius`.
133///
134/// `nlm_bilateral` loads a `(32 + 2r) x (8 + 2r)` tile of
135/// `Vector<f32, N>` into shared memory. `N` reaches 4 for YUV storage at
136/// 4 bytes per `f32`, so the tile costs `16 * (32 + 2r) * (8 + 2r)`
137/// bytes.
138///
139/// RDNA-class hardware gives 64 KiB of shared memory to work with. At
140/// `r = 22` the tile is 63,232 bytes, or 61.75 KiB, which fits. At
141/// `r = 23` it is 67,392 bytes, or 65.8 KiB, which does not.
142///
143/// So 22 is the largest radius that fits, and `bilateral_radius` reaches
144/// it at `sigma_s = 11.0`.
145pub const MAX_BILATERAL_RADIUS: u32 = 22;
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148/// Which channels of a frame the denoiser works on.
149pub enum ChannelMode {
150    /// The single brightness channel, with distances scaled by 3.0.
151    Luma,
152    /// The two colour channels, U and V, with distances scaled by 1.5.
153    Chroma,
154    /// All three channels together, with distances left unscaled.
155    Yuv,
156}
157
158impl ChannelMode {
159    /// How many channels take part in the distance and the output.
160    pub fn count(self) -> u32 {
161        match self {
162            ChannelMode::Luma => 1,
163            ChannelMode::Chroma => 2,
164            ChannelMode::Yuv => 3,
165        }
166    }
167
168    /// How many channels each pixel occupies in GPU storage.
169    ///
170    /// This is padded up to the next supported vector width so kernels
171    /// can read whole `Line<f32>` values at once. Backends only support
172    /// power-of-two widths, so YUV pads from 3 up to 4.
173    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/// Parameters for the quality-focused `nlmeans-hq` variant.
183///
184/// The measured noise level drives both the effective strength and the
185/// distance floor, so the weighting adapts to how noisy the source
186/// really is.
187#[derive(Debug, Clone, Copy, PartialEq)]
188pub struct HqParams {
189    /// Reads `strength` as a multiplier on the noise level, making the
190    /// effective FFmpeg-style strength `strength * sigma_eff * 255`.
191    ///
192    /// Defaults to true.
193    pub auto_strength: bool,
194    /// Subtracts the expected noise floor from patch distances before
195    /// weighting, so a match is not penalised for the noise it carries.
196    ///
197    /// Defaults to true.
198    pub noise_floor: bool,
199    /// A fixed noise standard deviation in `[0, 1]` units, replacing the
200    /// automatic per-frame estimate.
201    ///
202    /// `None`, the default, measures the noise in each pushed frame and
203    /// smooths it over time. `Some` applies one fixed value to every
204    /// frame instead.
205    ///
206    /// The CLI takes this in 8-bit units through `--hq-sigma` and
207    /// divides by 255.
208    pub sigma_override: Option<f32>,
209    /// Weights each temporal neighbour by how well it block-matches the
210    /// centre frame, so occlusion or a change of content collapses its
211    /// contribution rather than blurring it in.
212    ///
213    /// Only has an effect when `temporal_radius` is above 0. Defaults to
214    /// true.
215    pub temporal_confidence: bool,
216    /// A multiplier on the per-pixel mismatch threshold, which sets how
217    /// much extra SAD a block tolerates before its confidence starts to
218    /// fall.
219    ///
220    /// Higher values tolerate larger mismatches. Defaults to 1.0.
221    pub thsad_scale: f32,
222    /// A multiplier applied to each channel's measured sigma before it
223    /// folds into the running estimate.
224    ///
225    /// `1.0`, the default, keeps the measurement as it is. This does
226    /// nothing when `sigma_override` is set, because the estimator never
227    /// runs in that case.
228    ///
229    /// The CLI takes this as `--hq-sigma-scale`.
230    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    /// The HQ defaults with a fixed noise level in `[0, 1]` units, which
248    /// skips automatic estimation.
249    pub fn with_sigma(sigma: f32) -> Self {
250        Self {
251            sigma_override: Some(sigma),
252            ..Self::default()
253        }
254    }
255}
256
257/// The low-level parameters a denoiser is built from.
258#[derive(Debug, Clone)]
259pub struct NlmParams {
260    /// How many frames on each side of the current one to look at.
261    ///
262    /// 0 means each frame is cleaned on its own. Anything higher uses a
263    /// window of `2 * radius + 1` frames.
264    pub temporal_radius: u32,
265    /// Half the width of the search window, which covers
266    /// `(2 * radius + 1)^2` pixels. Defaults to 2.
267    pub search_radius: u32,
268    /// Half the width of a compared patch, which covers
269    /// `(2 * radius + 1)^2` pixels. Defaults to 4.
270    pub patch_radius: u32,
271    /// How hard to filter. Higher values smooth more. Defaults to 1.2.
272    pub strength: f32,
273    /// How much weight the centre pixel gets in the average.
274    ///
275    /// Defaults to 1.0. Set it to 0 for pure NLM, where the centre pixel
276    /// only counts through the patches that match it.
277    pub self_weight: f32,
278    /// Which channels to process.
279    pub channels: ChannelMode,
280    /// Which image the patch distances are measured against.
281    ///
282    /// Defaults to `None`. When set, the weights come from a prefiltered
283    /// or externally supplied image while the pixels being averaged
284    /// still come from the original input.
285    pub prefilter: PrefilterMode,
286    /// Whether temporal denoising follows motion between frames.
287    ///
288    /// Defaults to `None`. Set to `Mvtools`, each submit warps the
289    /// temporal neighbours into line with the centre frame before the
290    /// NLM weighting runs.
291    ///
292    /// Only has an effect when `temporal_radius` is above 0.
293    pub motion_compensation: MotionCompensationMode,
294    /// The quality-mode parameters. `None` runs the fast path unchanged.
295    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    /// The FFmpeg-style strength the weighting actually uses.
316    ///
317    /// With HQ auto-strength the user's value multiplies the noise
318    /// level, so one setting follows sources of different noisiness.
319    ///
320    /// `sigma_eff` is the scale-weighted RMS of the per-channel noise
321    /// estimates.
322    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    /// `h2_inv_norm` for a noise estimate given here, ignoring whatever
330    /// `self.hq.sigma_override` holds.
331    ///
332    /// The denoiser calls this each submit to refresh the value from a
333    /// freshly measured sigma.
334    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    /// `h2_inv_norm` using `self.hq.sigma_override` as the noise
341    /// estimate, or no estimate at all on the fast path.
342    ///
343    /// HQ denoisers that estimate noise automatically call
344    /// [`Self::h2_inv_norm_with`] each submit instead.
345    pub fn h2_inv_norm(&self) -> f32 {
346        self.h2_inv_norm_with(self.hq.and_then(|hq| hq.sigma_override))
347    }
348
349    /// The patch distance two noisy copies of the same content are
350    /// expected to show, for a given set of per-channel sigmas.
351    ///
352    /// Each active channel contributes `2 * channel_scale * sigma^2`,
353    /// summed over all `(2 * patch_radius + 1)^2` taps.
354    ///
355    /// Returns 0 when the HQ noise floor is off, or when there is no
356    /// estimate to apply.
357    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    /// `noise_offset` with `self.hq.sigma_override` applied to every
371    /// active channel, or no estimate at all on the fast path.
372    ///
373    /// HQ denoisers that estimate noise automatically call
374    /// [`Self::noise_offset_with`] each submit instead.
375    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    /// Rejects parameter combinations that would fail to launch, by
390    /// running the kernels past their shared-memory or register limits,
391    /// or that would produce meaningless output.
392    ///
393    /// `NlmDenoiser::new` calls this for you. Callers building params by
394    /// hand can call it directly to see errors before construction.
395    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            // sigma_s decides the shared-memory tile radius through
487            // `prefilter::bilateral_radius`. Checking that derived
488            // radius, rather than working out an equivalent sigma_s
489            // threshold here, keeps this in step if the formula ever
490            // changes.
491            //
492            // A very large sigma_s can also overflow the radius inside
493            // the tile-size expression. This check catches that too,
494            // because an overflowed radius always lands far past the
495            // maximum.
496            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            // A sigma can be finite and positive yet small enough that
509            // `sigma * sigma` underflows to 0.0 in f32, which happens
510            // below roughly 3.8e-20. That makes the reciprocal
511            // normalisation factor the kernel uses infinite.
512            //
513            // Checking the same derived factor `run_bilateral` computes
514            // for the launch catches this wherever the underflow
515            // threshold actually falls, without picking a sigma cutoff
516            // by hand or repeating the expression here.
517            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
558/// The per-channel distance scale for a channel mode, which is 3 for
559/// luma, 1.5 for chroma, and 1 for full YUV.
560///
561/// This matches the `channel_scale` the weighting kernels use on the
562/// GPU, and it is the same for every channel within a given mode.
563pub(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
571/// The scale-weighted RMS of the per-channel noise estimates, over the
572/// channels a mode actually uses.
573///
574/// Because `channel_scale` is the same for every channel in a mode, the
575/// weighting cancels out and this is really just a plain RMS.
576///
577/// Anything in `sigmas` past the mode's channel count is ignored.
578pub(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        // The chroma scale of 1.5 applies per channel, and each channel
806        // keeps its own sigma rather than sharing one.
807        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        // A sigma_r of 0 makes inv_two_sigma_r_sq infinite. The centre
861        // tap's range_sq is 0, and 0 times infinity is NaN, which
862        // poisons every pixel of the reference image.
863        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        // A sigma_s of 0 makes inv_two_sigma_s_sq infinite. The centre
903        // tap's spatial_dist_sq is 0, so the spatial term is poisoned by
904        // the same 0 times infinity NaN.
905        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        // Pins the guard to `<= 0.0` rather than `< 0.0`. A value that
957        // is small but strictly positive, and far enough from the f32
958        // underflow cliff that squaring it stays a normal float, has to
959        // be accepted for either field on its own.
960        //
961        // 1e-6 squares to 1e-12, nowhere near the smallest normal f32 of
962        // about 1.18e-38, so `inv_two_sigma_sq` stays finite here.
963        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        // `f32::MIN_POSITIVE`, the smallest normal positive f32 at about
991        // 1.1754944e-38, is finite and above 0, so a guard that only
992        // checked the raw value let it through.
993        //
994        // Squaring it underflows to exactly 0.0 in f32, because its true
995        // square of about 1.38e-76 is far below the smallest subnormal
996        // of about 1.4e-45. `inv_two_sigma_sq` then divides by zero and
997        // returns infinity.
998        //
999        // That is the same NaN poisoning this validation exists to
1000        // prevent, reached through a sigma other than exactly 0.0.
1001        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    /// A `sigma_s` of 16.0 gives a bilateral radius of 32, which is the
1035    /// worked example in `prefilter.rs` and well past the maximum of 22.
1036    #[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    /// A `sigma_s` of 1e9 overflows the tile-size arithmetic if it ever
1053    /// reaches the kernel launch.
1054    ///
1055    /// Validation has to reject it long before that, through the same
1056    /// radius check any other oversized `sigma_s` hits.
1057    #[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    /// The boundary pair for [`MAX_BILATERAL_RADIUS`], written as
1070    /// literal `sigma_s` values rather than derived from the constant.
1071    ///
1072    /// A `sigma_s` of 11.0 gives a radius of 22, right at the ceiling,
1073    /// so it is accepted. A `sigma_s` of 11.01 gives 23, one past it, so
1074    /// it is rejected.
1075    #[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        // Luma only reads the first element, even when handed extra
1102        // chroma samples.
1103        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}