Skip to main content

av_denoise/nlmeans/
params.rs

1use super::{MotionCompensationMode, PrefilterMode, prefilter};
2
3/// SSD normalisation reference, matching FFmpeg's nlmeans (255² for
4/// 8-bit normalisation). Distances are computed in `[0, 1]` units so
5/// this folds in the implied scale-up.
6pub(super) const NLM_NORM: f32 = 255.0 * 255.0;
7/// Legacy scaling factor inherited from FFmpeg's nlmeans; preserved so
8/// our `strength` parameter has equivalent meaning.
9pub(super) const NLM_LEGACY: f32 = 3.0;
10
11/// Measured HQ default `strength` per temporal radius for the luma
12/// table, `hq_default_strength`'s `ChannelMode::Luma`/`ChannelMode::Yuv`
13/// source. Index is `temporal_radius.min(8)`.
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];
15
16/// Measured HQ default `strength` per temporal radius for the chroma
17/// table, `hq_default_strength`'s `ChannelMode::Chroma` source. Index is
18/// `temporal_radius.min(8)`.
19const 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
21/// Calibrated default `strength` multiplier for `nlmeans-hq`'s
22/// auto-strength mode, as a function of which plane is denoised and how
23/// far the temporal window reaches.
24///
25/// Each entry is a measured value, not a fitted curve. Both tables
26/// come from quality-harness sweeps that score a strength grid at
27/// three noise levels per radius.
28///
29/// The luma sweep covers every radius 0..=8 directly.
30/// The chroma sweep pins luma at each radius's chosen
31/// value, so the chroma metrics stay uncontaminated, and covers radii
32/// 0, 1, 2, 4, and 8 with a bracketed peak at each.
33///
34/// Radius 0 peaks at 1.00, radius 1 at 0.85, and radii 2, 4,
35/// and 8 all land at 0.70, a plateau that does not keep falling
36/// with radius the way luma does.
37///
38/// Radii 3, 5, 6, and 7 sit on that measured flat plateau between the
39/// r2/r4/r8 anchors rather than being swept directly. For both tables
40/// the value picked at each measured radius is the one whose smallest
41/// XPSNR gain across the tested noise levels (max-min) is largest, so
42/// the choice holds up at whichever noise level is hardest to serve.
43///
44/// Luma stays monotonically non-increasing with radius, since wider
45/// temporal windows already gather more samples to average over.
46///
47/// Chroma drops the same way out to r2 and then holds flat, rather than
48/// continuing to decrease.
49///
50/// `ChannelMode::Yuv` reads the luma table on the assumption that a
51/// fused pass is luma-dominant. That mode wasn't part of the sweep, so
52/// this is an assumption rather than a separate measurement.
53///
54/// `temporal_radius` is clamped to the table's last index
55/// (`temporal_radius.min(8)`, matching [`MAX_TEMPORAL_RADIUS`]) as a
56/// belt-and-braces guard. Callers should already reject radii above the
57/// maximum in [`NlmParams::validate`].
58pub 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
66/// Smallest supported frame side length. The Immerkær noise estimate
67/// only measures interior pixels (the 3×3 mask cannot reach the
68/// one-pixel border), so a frame narrower or shorter than 3 pixels
69/// has no interior at all and the estimate is undefined.
70pub const MIN_FRAME_DIM: u32 = 3;
71
72/// Reject frame dimensions the kernels cannot handle. Called by both
73/// denoiser constructors before any buffer is allocated.
74pub 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
85/// Patch radius threshold: above this the dispatcher switches to the
86/// separable path so the per-pixel cost stays linear in `patch_radius`.
87pub(super) const SEPARABLE_THRESHOLD: u32 = 8;
88
89/// Hard ceiling on `patch_radius`. The fused kernels load a
90/// `(block + 2·patch_radius)²` SMEM tile; values above this run out of
91/// SMEM on RDNA-class GPUs.
92pub const MAX_PATCH_RADIUS: u32 = 16;
93
94/// Hard ceiling on `search_radius`. The windowed kernel's SMEM tile is
95/// `(block + 2·patch_radius + 2·search_radius)² × stored_ch × 4` bytes,
96/// well within hardware SMEM limits at every supported size. The real
97/// cost that grows with `search_radius` is the kernel's `#[unroll]`ed
98/// `(2·search_radius + 1)²` window loop, whose compiled size and
99/// codegen time both scale with it (see the stack-size note in
100/// `.cargo/config.toml`). The per-q dispatch path (used when
101/// `patch_radius` forces the separable fallback) is gated on this too,
102/// so launch counts stay sane (`(2·a+1)²` launches per temporal
103/// offset).
104pub const MAX_SEARCH_RADIUS: u32 = 8;
105
106/// Hard ceiling on `temporal_radius`. The ring buffer is sized for
107/// `2·t + 1` frames; values above this consume excessive device memory
108/// (e.g. 1080p YUV at `t = 16` ≈ 540 MB just for input).
109pub const MAX_TEMPORAL_RADIUS: u32 = 8;
110
111/// Hard ceiling on the bilateral prefilter's derived radius
112/// (`prefilter::bilateral_radius(sigma_s)`, `radius = ceil(2 ·
113/// sigma_s).max(1)`). `nlm_bilateral` loads a `(32 + 2r) × (8 + 2r)`
114/// tile of `Vector<f32, N>` into shared memory, `N` up to 4 for YUV
115/// storage, 4 bytes per `f32`, so the tile costs `16 * (32 + 2r) * (8
116/// + 2r)` bytes.
117///
118/// Targeting the 64 KiB (65536-byte) SMEM budget on RDNA-class
119/// hardware, `r = 22` costs `16 * 76 * 52` bytes, `63232` bytes or
120/// `61.75` KiB, still under budget. `r = 23` costs `16 * 78 * 54`
121/// bytes, `67392` bytes or `65.8` KiB, over it. So 22 is the largest
122/// radius that fits, which `bilateral_radius` reaches at `sigma_s =
123/// 11.0`.
124pub const MAX_BILATERAL_RADIUS: u32 = 22;
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127/// How to apply denoising to the input frame channels.
128pub enum ChannelMode {
129    /// Single luminance channel. Distance scaled by 3.0.
130    Luma,
131    /// Two chroma channels (U, V). Distance scaled by 1.5.
132    Chroma,
133    /// Three channels (Y, U, V). Unscaled sum of squared differences.
134    Yuv,
135}
136
137impl ChannelMode {
138    /// Number of meaningful channels participating in distance/output.
139    pub fn count(self) -> u32 {
140        match self {
141            ChannelMode::Luma => 1,
142            ChannelMode::Chroma => 2,
143            ChannelMode::Yuv => 3,
144        }
145    }
146
147    /// Channels-per-pixel in GPU storage. Padded up to the next supported
148    /// vectorization factor so kernels can use coalesced `Line<f32>` reads
149    /// (backends only support power-of-two line sizes; YUV pads to 4).
150    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/// Parameters for the quality-focused `nlmeans-hq` variant. The noise
160/// level drives both the effective strength and the distance floor,
161/// so weighting adapts to how noisy the source actually is.
162#[derive(Debug, Clone, Copy, PartialEq)]
163pub struct HqParams {
164    /// Interpret `strength` as a multiplier on the noise level. The
165    /// effective FFmpeg-style strength becomes
166    /// `strength × sigma_eff × 255`. Default: true.
167    pub auto_strength: bool,
168    /// Subtract the expected noise floor from patch distances before
169    /// weighting, so matches are not penalised for the noise they
170    /// carry. Default: true.
171    pub noise_floor: bool,
172    /// Fixed noise standard deviation in `[0, 1]` units, overriding
173    /// automatic per-frame estimation. `None` (the default) measures
174    /// noise from each pushed frame and smooths it over time. `Some`
175    /// applies one fixed value to every frame instead. The CLI takes
176    /// this in 8-bit units via `--hq-sigma` and divides by 255.
177    pub sigma_override: Option<f32>,
178    /// Weight temporal neighbours by how well they block-match the
179    /// centre frame, so occlusion or content change collapses their
180    /// contribution instead of blurring it in. Only takes effect when
181    /// `temporal_radius > 0`. Default: true.
182    pub temporal_confidence: bool,
183    /// Multiplier on the per-pixel mismatch threshold that decides how
184    /// much excess SAD a block tolerates before its confidence starts
185    /// dropping. Higher values tolerate larger mismatches. Default: 1.0.
186    pub thsad_scale: f32,
187    /// Multiplier applied to each channel's automatically measured
188    /// sigma before it folds into the running estimate. `1.0` keeps
189    /// the measurement as-is. Has no effect when `sigma_override` is
190    /// set, since the estimation path never runs in that case.
191    /// Default: 1.0. The CLI takes this as `--hq-sigma-scale`.
192    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    /// HQ defaults with a fixed noise level in `[0, 1]` units, skipping
210    /// automatic estimation.
211    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    /// Temporal radius. 0 = spatial only, d > 0 uses 2*d+1 frames.
222    pub temporal_radius: u32,
223    /// Search window half-size. Search window is (2*a+1)^2. Default: 2.
224    pub search_radius: u32,
225    /// Patch comparison half-size. Patch is (2*s+1)^2. Default: 4, range [0, 8].
226    pub patch_radius: u32,
227    /// Filtering strength. Higher = more smoothing. Default: 1.2.
228    pub strength: f32,
229    /// Self-weight multiplier. Default: 1.0. Set to 0 for pure NLM.
230    pub self_weight: f32,
231    /// Which channels to process.
232    pub channels: ChannelMode,
233    /// Reference clip source used for patch-distance / weight
234    /// computation. Default: `None`. When set, weights are derived
235    /// from a prefiltered or externally-supplied clip while pixel
236    /// accumulation continues to read the original input.
237    pub prefilter: PrefilterMode,
238    /// Motion-compensation mode. Default: `None`. When set to
239    /// `Mvtools`, each `denoise_submit` warps the temporal neighbours
240    /// into spatial alignment with the centre before NLM weighting.
241    /// Only takes effect when `temporal_radius > 0`.
242    pub motion_compensation: MotionCompensationMode,
243    /// Quality-mode parameters. `None` runs the fast path unchanged.
244    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    /// FFmpeg-style strength actually used for weighting. With HQ
265    /// auto strength the user value multiplies the noise level, so
266    /// one setting tracks differently noisy sources. `sigma_eff` is
267    /// the scale-weighted RMS of the per-channel noise estimates.
268    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    /// `h2_inv_norm` for an explicit noise estimate, bypassing whatever
276    /// `self.hq.sigma_override` holds. Used by the denoiser to refresh
277    /// the derived value from a freshly measured sigma each submit.
278    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    /// `h2_inv_norm` using `self.hq.sigma_override` as the noise
285    /// estimate (or none, for the fast path). Auto-estimating HQ
286    /// denoisers recompute with [`Self::h2_inv_norm_with`] each submit
287    /// instead of calling this.
288    pub fn h2_inv_norm(&self) -> f32 {
289        self.h2_inv_norm_with(self.hq.and_then(|hq| hq.sigma_override))
290    }
291
292    /// Expected box-summed patch distance between two noisy copies of
293    /// identical content, for an explicit set of per-channel sigma
294    /// estimates. Each active channel contributes `2 × channel_scale ×
295    /// σ_c²`, summed over `(2s+1)²` taps. Zero without HQ noise floor
296    /// or without an estimate to apply.
297    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    /// `noise_offset` using `self.hq.sigma_override` applied to every
311    /// active channel (or none, for the fast path). Auto-estimating HQ
312    /// denoisers recompute with [`Self::noise_offset_with`] each submit
313    /// instead of calling this.
314    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    /// Reject parameter combinations that would either fail to launch
329    /// (kernels hitting SMEM/register limits) or produce numerically
330    /// degenerate output. Called automatically by `NlmDenoiser::new`;
331    /// callers building params manually can invoke it directly to
332    /// surface errors before construction.
333    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            // sigma_s drives a comptime SMEM tile radius
420            // (`prefilter::bilateral_radius`). Checking the actual
421            // derived radius, rather than re-deriving a sigma_s
422            // threshold here, keeps this in sync automatically if that
423            // formula ever changes. A very large sigma_s can also make
424            // the radius overflow `u32` inside the comptime tile-size
425            // expression. This catches that too, since the overflowed
426            // radius is always far past the maximum.
427            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            // A `sigma` can be finite and positive yet still be small
439            // enough that `sigma * sigma` underflows to `0.0` in f32
440            // (true for any positive value below roughly 3.8e-20 here),
441            // which makes the reciprocal normalisation factor the
442            // kernel actually uses infinite. Checking the same derived
443            // factor `run_bilateral` computes for the kernel launch
444            // catches that regardless of exactly where the underflow
445            // threshold falls, rather than picking a sigma-space cutoff
446            // by hand or duplicating the expression here.
447            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
488/// Per-channel distance scale for a channel mode (luma×3, chroma×1.5,
489/// full YUV×1), matching the GPU-side `channel_scale` used inside the
490/// weighting kernels. Uniform across every channel in a given mode.
491pub(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
499/// Scale-weighted RMS of the per-channel noise estimates over a
500/// channel mode's active channel count. Because `channel_scale` is
501/// uniform within a mode, the weighting cancels and this reduces to a
502/// plain RMS. Extra elements in `sigmas` beyond the mode's channel
503/// count are ignored.
504pub(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        // Chroma scale is 1.5, applied per channel; each channel keeps
732        // its own sigma instead of a shared value.
733        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        // sigma_r = 0 makes inv_two_sigma_r_sq = 1/0 = inf. The centre
787        // tap's range_sq is 0, so 0 * inf = NaN, which poisons every
788        // pixel of the reference clip.
789        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        // sigma_s = 0 makes inv_two_sigma_s_sq = 1/0 = inf; the centre
829        // tap's spatial_dist_sq is 0, so the same 0 * inf = NaN poisoning
830        // applies to the spatial term.
831        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        // Pins the guard to `<= 0.0` rather than `< 0.0`: a value that is
883        // small but strictly positive, and far enough from the f32
884        // underflow cliff that `sigma * sigma` stays a normal (non-zero)
885        // float, must still be accepted for both fields, independently
886        // of the other one. 1e-6 squares to 1e-12, nowhere near the
887        // smallest normal f32 (~1.18e-38), so `inv_two_sigma_sq` stays
888        // finite here.
889        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        // `f32::MIN_POSITIVE` (the smallest *normal* positive f32,
917        // ~1.1754944e-38) is finite and > 0, so the old `is_finite() &&
918        // > 0.0` guard on the raw value let it through. But squaring it
919        // underflows to exactly `0.0` in f32 (its true square,
920        // ~1.38e-76, is far below the smallest subnormal, ~1.4e-45), so
921        // `inv_two_sigma_sq` — `1.0 / (2.0 * sigma * sigma)` — divides
922        // by zero and produces `inf`. That's the exact NaN-poisoning
923        // failure mode this validation exists to prevent, just reached
924        // through a different sigma than exactly `0.0`.
925        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    /// `sigma_s = 16.0` gives `bilateral_radius = 32` (`prefilter.rs`'s
962    /// own worked example), well past [`MAX_BILATERAL_RADIUS`] (22).
963    #[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    /// `sigma_s = 1e9` overflows `bilateral_radius`'s comptime tile-size
980    /// arithmetic if it ever reaches the kernel launch. Validation must
981    /// reject it long before that, via the same radius check as any
982    /// other oversized `sigma_s`.
983    #[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    /// Boundary pair for [`MAX_BILATERAL_RADIUS`], pinned as literal
996    /// `sigma_s` values rather than derived from the constant itself.
997    /// `bilateral_radius(11.0) = ceil(22.0) = 22` (at the ceiling,
998    /// survives) and `bilateral_radius(11.01) = ceil(22.02) = 23` (one
999    /// past it, rejected).
1000    #[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        // Luma only looks at the first element even when given extra
1027        // (chroma) samples.
1028        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}