Skip to main content

av_denoise/
denoiser.rs

1use std::collections::VecDeque;
2
3use cubecl::Runtime;
4
5use crate::accelerate::Accelerator;
6use crate::device::Device;
7#[cfg(test)]
8use crate::nlmeans::MotionEstimation;
9use crate::nlmeans::{
10    ChannelMode,
11    HqParams,
12    MotionCompensationMode,
13    NlmDenoiser,
14    NlmParams,
15    Pending,
16    PrefilterMode,
17    hq_default_strength,
18    validate_dimensions,
19};
20use crate::sniff::sniff_best_accelerator;
21
22/// User-facing denoiser configuration. Build with `DenoiserOptions::builder()`.
23#[derive(Debug, Clone, bon::Builder)]
24pub struct DenoiserOptions {
25    /// Which channels of the frame to denoise.
26    #[builder(default = ChannelMode::Yuv)]
27    pub channel_mode: ChannelMode,
28    /// Spatial-only or temporal denoising.
29    #[builder(default = DenoisingMode::Spacial)]
30    pub mode: DenoisingMode,
31    /// Algorithm variant. `Nlmeans` is the fast default. `NlmeansHq`
32    /// adapts weighting to the noise level, measured automatically per
33    /// frame unless `HqParams::sigma_override` pins a fixed value. Its
34    /// default `strength` multiplier is also different, and adapts to
35    /// the temporal radius and the plane being denoised, see
36    /// [`crate::nlmeans::hq_default_strength`].
37    #[builder(default = Algorithm::Nlmeans)]
38    pub algorithm: Algorithm,
39    /// Reference clip source for NLM weight computation. `None` (the
40    /// default) uses no prefilter for either algorithm. Set
41    /// `PrefilterMode::NlmSpatial` explicitly to opt into the NLM
42    /// spatial pilot.
43    pub prefilter: Option<PrefilterMode>,
44    /// Motion-compensation mode for temporal denoising. `None`
45    /// disables MC; `Mvtools` warps temporal neighbours into spatial
46    /// alignment with the centre frame before NLM weighting. Only
47    /// takes effect when `mode` is `Temporal { .. }`.
48    #[builder(default = MotionCompensationMode::None)]
49    pub motion_compensation: MotionCompensationMode,
50    /// Override NLM tuning (search/patch radius, strength, self-weight).
51    /// `None` uses the defaults baked into [`NlmParams`].
52    pub nlm: Option<NlmTuning>,
53}
54
55/// Which denoising algorithm variant to run.
56#[derive(Debug, Copy, Clone, PartialEq)]
57pub enum Algorithm {
58    /// The fast NLMeans path.
59    Nlmeans,
60    /// Quality-focused NLMeans with noise-calibrated weighting.
61    NlmeansHq(HqParams),
62}
63
64/// Standard spatial or temporal-aware denoising.
65#[derive(Debug, Copy, Clone, Eq, PartialEq)]
66pub enum DenoisingMode {
67    /// Spatial-only denoising (single frame).
68    Spacial,
69    /// Temporal-aware denoising over a `2 * radius + 1` window.
70    Temporal { radius: u32 },
71}
72
73/// NLM tuning knobs. All optional; missing fields fall back to library
74/// defaults.
75#[derive(Debug, Copy, Clone)]
76pub struct NlmTuning {
77    pub search_radius: Option<u32>,
78    pub patch_radius: Option<u32>,
79    pub strength: Option<f32>,
80    pub self_weight: Option<f32>,
81}
82
83impl DenoiserOptions {
84    /// Resolve this option set into the low-level [`NlmParams`] a
85    /// backend `Denoiser` is actually built from, folding in whichever
86    /// default `strength` applies (see [`crate::nlmeans::hq_default_strength`]
87    /// for the HQ algorithm). Exposed (rather than kept private) so
88    /// callers building per-plane options, and tests, can inspect the
89    /// resolved values without constructing a real `Denoiser`.
90    #[doc(hidden)]
91    pub fn to_nlm_params(&self) -> NlmParams {
92        let temporal_radius = match self.mode {
93            DenoisingMode::Spacial => 0,
94            DenoisingMode::Temporal { radius } => radius,
95        };
96
97        // An explicit `strength` (whether from `NlmTuning` directly or a
98        // per-plane override already folded into it by the caller) always
99        // wins. Otherwise the default depends on the algorithm and, for
100        // HQ, on `auto_strength`. With auto-strength on, HQ's `strength`
101        // is a multiplier on the measured noise level, so it needs its
102        // own calibrated default rather than the fast path's absolute
103        // FFmpeg-style default. That default also depends on the
104        // temporal radius and which plane `self.channel_mode` names,
105        // since each per-plane `Denoiser` carries its own channel mode.
106        // With auto-strength off, HQ's `strength` is used verbatim as an
107        // absolute value, same as the fast path, so it falls back to the
108        // same absolute default.
109        let explicit_strength = self.nlm.and_then(|t| t.strength);
110        let strength = explicit_strength.unwrap_or(match self.algorithm {
111            // The calibrated table is a multiplier on measured sigma, so
112            // it only applies when `effective_strength_with` will treat
113            // `strength` that way. With auto-strength off, `strength` is
114            // used verbatim as an FFmpeg-style absolute value, so the
115            // fallback has to be the same absolute default the fast path
116            // uses.
117            Algorithm::NlmeansHq(hq) if hq.auto_strength => {
118                hq_default_strength(self.channel_mode, temporal_radius)
119            },
120            Algorithm::NlmeansHq(_) | Algorithm::Nlmeans => NlmParams::default().strength,
121        });
122
123        let mut params = NlmParams {
124            channels: self.channel_mode,
125            prefilter: self.prefilter.unwrap_or(PrefilterMode::None),
126            motion_compensation: self.motion_compensation,
127            temporal_radius,
128            hq: match self.algorithm {
129                Algorithm::Nlmeans => None,
130                Algorithm::NlmeansHq(hq) => Some(hq),
131            },
132            strength,
133            ..NlmParams::default()
134        };
135        if let Some(t) = self.nlm {
136            if let Some(v) = t.search_radius {
137                params.search_radius = v;
138            }
139            if let Some(v) = t.patch_radius {
140                params.patch_radius = v;
141            }
142            if let Some(v) = t.self_weight {
143                params.self_weight = v;
144            }
145        }
146        params
147    }
148}
149
150/// Errors surfaced from the high-level [`Denoiser`].
151#[derive(Debug, thiserror::Error)]
152pub enum DenoiserError {
153    /// A previous denoised frame hasn't been collected yet and the
154    /// internal double-buffered output slot would alias. Call
155    /// [`Denoiser::recv_frame`] or [`Denoiser::try_recv_frame`] first,
156    /// then retry the same `push_frame` call.
157    #[error("denoiser queue is full; collect the pending frame before pushing more")]
158    QueueFull,
159    /// None of the accelerators in the priority list could be initialised.
160    #[error("no accelerator from the priority list is available")]
161    NoAcceleratorAvailable,
162    /// Catch-all wrapping internal `anyhow` errors from kernel dispatch
163    /// and readback.
164    #[error(transparent)]
165    Other(#[from] anyhow::Error),
166}
167
168enum Backend {
169    #[cfg(feature = "cuda")]
170    Cuda(NlmDenoiser<cubecl::cuda::CudaRuntime>),
171    #[cfg(feature = "rocm")]
172    Rocm(NlmDenoiser<cubecl::hip::HipRuntime>),
173    #[cfg(any(feature = "vulkan", feature = "metal"))]
174    Wgpu(NlmDenoiser<cubecl::wgpu::WgpuRuntime>),
175    #[cfg(feature = "cpu")]
176    Cpu(NlmDenoiser<cubecl::cpu::CpuRuntime>),
177}
178
179enum BackendPending {
180    #[cfg(feature = "cuda")]
181    Cuda(Pending<cubecl::cuda::CudaRuntime>),
182    #[cfg(feature = "rocm")]
183    Rocm(Pending<cubecl::hip::HipRuntime>),
184    #[cfg(any(feature = "vulkan", feature = "metal"))]
185    Wgpu(Pending<cubecl::wgpu::WgpuRuntime>),
186    #[cfg(feature = "cpu")]
187    Cpu(Pending<cubecl::cpu::CpuRuntime>),
188}
189
190impl BackendPending {
191    fn wait(self) -> Result<Vec<f32>, anyhow::Error> {
192        match self {
193            #[cfg(feature = "cuda")]
194            Self::Cuda(p) => p.wait(),
195            #[cfg(feature = "rocm")]
196            Self::Rocm(p) => p.wait(),
197            #[cfg(any(feature = "vulkan", feature = "metal"))]
198            Self::Wgpu(p) => p.wait(),
199            #[cfg(feature = "cpu")]
200            Self::Cpu(p) => p.wait(),
201        }
202    }
203}
204
205/// High-level stateful denoiser. Push frames in order with
206/// [`push_frame`](Self::push_frame); collect denoised frames with
207/// [`recv_frame`](Self::recv_frame) or
208/// [`try_recv_frame`](Self::try_recv_frame); call [`flush`](Self::flush)
209/// at end-of-stream to drain any remaining temporal context.
210/// Maximum number of outstanding `Pending` readbacks the high-level
211/// [`Denoiser`] keeps in flight. Must equal the backend's output-handle
212/// count ([`crate::nlmeans::NlmDenoiser::outputs`] is `[Handle; 2]`).
213/// Exceeding this aliases the oldest pending's output handle and
214/// silently corrupts results.
215const MAX_PENDING: usize = 2;
216
217pub struct Denoiser {
218    backend: Backend,
219    pending: VecDeque<BackendPending>,
220    accelerator: Accelerator,
221    width: u32,
222    height: u32,
223    channels: u32,
224    temporal_radius: u32,
225    frames_pushed: u32,
226}
227
228impl Denoiser {
229    /// Probe each accelerator in `accelerators` in order and build a
230    /// denoiser on the first one that's available. `device` lets the
231    /// caller pick a non-default device for the chosen runtime.
232    ///
233    /// # Thread stack size
234    ///
235    /// cubecl spawns an internal per-device worker thread (named
236    /// `DS{U,D}-…`) on which GPU kernel codegen runs. It uses Rust's
237    /// default thread stack (`RUST_MIN_STACK`, or 2 MiB if unset). The
238    /// windowed NLM kernels here contain `(2·search_radius + 1)²`-times
239    /// `#[unroll]`ed bodies, so large `search_radius` (≳ 5) values can
240    /// overflow that 2 MiB default and abort the process.
241    ///
242    /// Callers planning to use `search_radius > 4` should set
243    /// `RUST_MIN_STACK` to at least 16 MiB before any cubecl thread
244    /// spawns (typically at the very top of `main`), e.g.:
245    ///
246    /// ```no_run
247    /// if std::env::var_os("RUST_MIN_STACK").is_none() {
248    ///     // SAFETY: single-threaded at startup.
249    ///     unsafe { std::env::set_var("RUST_MIN_STACK", "16777216") };
250    /// }
251    /// ```
252    pub fn create(
253        accelerators: &[Accelerator],
254        device: &Device,
255        width: u32,
256        height: u32,
257        options: DenoiserOptions,
258    ) -> Result<Self, DenoiserError> {
259        let accelerator =
260            sniff_best_accelerator(accelerators).ok_or(DenoiserError::NoAcceleratorAvailable)?;
261
262        let params = options.to_nlm_params();
263        params.validate()?;
264        validate_dimensions(width, height)?;
265
266        let channels = params.channels.count();
267        let temporal_radius = params.temporal_radius;
268        let backend = build_backend(accelerator, device, params, width, height)?;
269
270        Ok(Self {
271            backend,
272            pending: VecDeque::with_capacity(MAX_PENDING),
273            accelerator,
274            width,
275            height,
276            channels,
277            temporal_radius,
278            frames_pushed: 0,
279        })
280    }
281
282    /// The accelerator picked by [`sniff_best_accelerator`].
283    pub fn selected_accelerator(&self) -> Accelerator {
284        self.accelerator
285    }
286
287    /// Width passed at construction.
288    pub fn width(&self) -> u32 {
289        self.width
290    }
291
292    /// Height passed at construction.
293    pub fn height(&self) -> u32 {
294        self.height
295    }
296
297    /// Upload one frame into the temporal window. `frame` must contain
298    /// `width * height * channels` `f32` values in `[0, 1]`. Once the
299    /// window is full and the in-flight pipeline has room, this also
300    /// kicks off the kernels for the next denoised frame.
301    ///
302    /// Up to `MAX_PENDING` outputs may be in flight simultaneously:
303    /// the GPU runs frame N+1's kernels while frame N's readback is in
304    /// flight. Returns [`DenoiserError::QueueFull`] once that ceiling
305    /// is reached; the caller must drain via [`Self::recv_frame`] before
306    /// pushing more.
307    pub fn push_frame(&mut self, frame: &[f32]) -> Result<(), DenoiserError> {
308        // After `temporal_radius` real pushes the leading-edge mirror has
309        // primed the window, so the next push will set a pending. From
310        // that point on, every push consumes a pending slot.
311        let window_full = self.frames_pushed > self.temporal_radius;
312        if window_full && self.pending.len() >= MAX_PENDING {
313            return Err(DenoiserError::QueueFull);
314        }
315
316        match &mut self.backend {
317            #[cfg(feature = "cuda")]
318            Backend::Cuda(d) => {
319                d.push_frame(frame);
320                if let Some(p) = d.denoise_submit()? {
321                    self.pending.push_back(BackendPending::Cuda(p));
322                }
323            },
324            #[cfg(feature = "rocm")]
325            Backend::Rocm(d) => {
326                d.push_frame(frame);
327                if let Some(p) = d.denoise_submit()? {
328                    self.pending.push_back(BackendPending::Rocm(p));
329                }
330            },
331            #[cfg(any(feature = "vulkan", feature = "metal"))]
332            Backend::Wgpu(d) => {
333                d.push_frame(frame);
334                if let Some(p) = d.denoise_submit()? {
335                    self.pending.push_back(BackendPending::Wgpu(p));
336                }
337            },
338            #[cfg(feature = "cpu")]
339            Backend::Cpu(d) => {
340                d.push_frame(frame);
341                if let Some(p) = d.denoise_submit()? {
342                    self.pending.push_back(BackendPending::Cpu(p));
343                }
344            },
345        }
346
347        self.frames_pushed = self.frames_pushed.saturating_add(1);
348        Ok(())
349    }
350
351    /// Block until the in-flight denoise completes and return the
352    /// denoised frame. Returns `Ok(None)` if nothing is in flight
353    /// (e.g. the temporal window isn't full yet).
354    pub fn recv_frame(&mut self) -> Result<Option<Vec<f32>>, DenoiserError> {
355        let Some(pending) = self.pending.pop_front() else {
356            return Ok(None);
357        };
358        Ok(Some(pending.wait()?))
359    }
360
361    /// Drain the in-flight denoise if one is ready. **May block
362    /// briefly** while the runtime confirms the readback has landed;
363    /// on workloads where kernels are already complete, the wait is
364    /// effectively immediate.
365    pub fn try_recv_frame(&mut self) -> Result<Option<Vec<f32>>, DenoiserError> {
366        self.recv_frame()
367    }
368
369    /// Drain in-flight frames and the trailing temporal tail (padded by duplicating
370    /// the last pushed frame), handing each produced frame to `sink`.
371    ///
372    /// On success the denoiser is left ready to accept a fresh,
373    /// independent stream of the same dimensions and parameters.
374    /// Pushing more frames after `flush` starts a new temporal window from
375    /// scratch. May be called multiple times.
376    ///
377    /// If `flush` returns `Err`, the denoiser is left in an undefined
378    /// state and should be dropped rather than reused.
379    pub fn flush(&mut self, mut sink: impl FnMut(Vec<f32>)) -> Result<(), DenoiserError> {
380        // Drain the full pending pipeline (up to MAX_PENDING frames) before
381        // submitting the trailing-tail mirrors.
382        while let Some(frame) = self.recv_frame()? {
383            sink(frame);
384        }
385
386        let pixels = (self.width * self.height) as usize;
387        let channels = self.channels as usize;
388        let scratch_cap = pixels * channels;
389
390        match &mut self.backend {
391            #[cfg(feature = "cuda")]
392            Backend::Cuda(d) => d.flush(|slice| {
393                let mut v = Vec::with_capacity(scratch_cap);
394                v.extend_from_slice(slice);
395                sink(v);
396            })?,
397            #[cfg(feature = "rocm")]
398            Backend::Rocm(d) => d.flush(|slice| {
399                let mut v = Vec::with_capacity(scratch_cap);
400                v.extend_from_slice(slice);
401                sink(v);
402            })?,
403            #[cfg(any(feature = "vulkan", feature = "metal"))]
404            Backend::Wgpu(d) => d.flush(|slice| {
405                let mut v = Vec::with_capacity(scratch_cap);
406                v.extend_from_slice(slice);
407                sink(v);
408            })?,
409            #[cfg(feature = "cpu")]
410            Backend::Cpu(d) => d.flush(|slice| {
411                let mut v = Vec::with_capacity(scratch_cap);
412                v.extend_from_slice(slice);
413                sink(v);
414            })?,
415        }
416
417        // Backend has already reset its own stream indices. Reset the
418        // outer push counter too so the next push re-arms the
419        // window-priming logic at the top of `push_frame`.
420        self.frames_pushed = 0;
421
422        Ok(())
423    }
424}
425
426fn build_backend(
427    accel: Accelerator,
428    device: &Device,
429    params: NlmParams,
430    width: u32,
431    height: u32,
432) -> Result<Backend, DenoiserError> {
433    match accel {
434        #[cfg(feature = "cuda")]
435        Accelerator::Cuda => {
436            let dev = device.to_cuda()?;
437            let client = <cubecl::cuda::CudaRuntime as Runtime>::client(&dev);
438            Ok(Backend::Cuda(NlmDenoiser::new(&client, params, width, height)))
439        },
440        #[cfg(feature = "rocm")]
441        Accelerator::Rocm => {
442            let dev = device.to_amd()?;
443            let client = <cubecl::hip::HipRuntime as Runtime>::client(&dev);
444            Ok(Backend::Rocm(NlmDenoiser::new(&client, params, width, height)))
445        },
446        #[cfg(feature = "vulkan")]
447        Accelerator::Vulkan => {
448            let dev = device.to_wgpu()?;
449            let client = <cubecl::wgpu::WgpuRuntime as Runtime>::client(&dev);
450            Ok(Backend::Wgpu(NlmDenoiser::new(&client, params, width, height)))
451        },
452        #[cfg(feature = "metal")]
453        Accelerator::Metal => {
454            let dev = device.to_wgpu()?;
455            let client = <cubecl::wgpu::WgpuRuntime as Runtime>::client(&dev);
456            Ok(Backend::Wgpu(NlmDenoiser::new(&client, params, width, height)))
457        },
458        #[cfg(feature = "cpu")]
459        Accelerator::Cpu => {
460            let dev = device.to_cpu()?;
461            let client = <cubecl::cpu::CpuRuntime as Runtime>::client(&dev);
462            Ok(Backend::Cpu(NlmDenoiser::new(&client, params, width, height)))
463        },
464        // Match-exhaustiveness placeholder for docs.rs, where `cfg(docsrs)`
465        // widens the `Accelerator` enum to include variants whose backend
466        // feature is not actually enabled. Never reached at runtime.
467        #[cfg(docsrs)]
468        #[allow(unreachable_patterns)]
469        _ => unreachable!(),
470    }
471}
472
473#[cfg(test)]
474mod options_tests {
475    use super::*;
476
477    #[test]
478    fn spatial_mode_maps_to_zero_temporal_radius() {
479        let opts = DenoiserOptions::builder()
480            .channel_mode(ChannelMode::Yuv)
481            .mode(DenoisingMode::Spacial)
482            .build();
483        let params = opts.to_nlm_params();
484
485        assert_eq!(params.temporal_radius, 0);
486        assert_eq!(params.channels, ChannelMode::Yuv);
487    }
488
489    #[test]
490    fn temporal_mode_propagates_radius() {
491        let opts = DenoiserOptions::builder()
492            .mode(DenoisingMode::Temporal { radius: 3 })
493            .build();
494        let params = opts.to_nlm_params();
495
496        assert_eq!(params.temporal_radius, 3);
497    }
498
499    #[test]
500    fn prefilter_passthrough() {
501        let opts = DenoiserOptions::builder()
502            .prefilter(PrefilterMode::Bilateral {
503                sigma_s: 3.0,
504                sigma_r: 0.02,
505            })
506            .build();
507        let params = opts.to_nlm_params();
508
509        assert!(matches!(params.prefilter, PrefilterMode::Bilateral { .. }));
510    }
511
512    #[test]
513    fn hq_unset_prefilter_defaults_to_none() {
514        let opts = DenoiserOptions::builder()
515            .algorithm(Algorithm::NlmeansHq(HqParams {
516                auto_strength: true,
517                noise_floor: true,
518                sigma_override: None,
519                temporal_confidence: true,
520                thsad_scale: 1.0,
521                sigma_scale: 1.0,
522            }))
523            .build();
524        let params = opts.to_nlm_params();
525
526        assert!(matches!(params.prefilter, PrefilterMode::None));
527    }
528
529    #[test]
530    fn hq_explicit_none_prefilter_is_respected() {
531        let opts = DenoiserOptions::builder()
532            .algorithm(Algorithm::NlmeansHq(HqParams {
533                auto_strength: true,
534                noise_floor: true,
535                sigma_override: None,
536                temporal_confidence: true,
537                thsad_scale: 1.0,
538                sigma_scale: 1.0,
539            }))
540            .prefilter(PrefilterMode::None)
541            .build();
542        let params = opts.to_nlm_params();
543
544        assert!(matches!(params.prefilter, PrefilterMode::None));
545    }
546
547    #[test]
548    fn fast_unset_prefilter_defaults_to_none() {
549        let opts = DenoiserOptions::builder().algorithm(Algorithm::Nlmeans).build();
550        let params = opts.to_nlm_params();
551
552        assert!(matches!(params.prefilter, PrefilterMode::None));
553    }
554
555    #[test]
556    fn hq_unset_strength_defaults_to_hq_default_strength() {
557        // Default channel_mode is Yuv, default mode is Spacial (radius 0).
558        let opts = DenoiserOptions::builder()
559            .algorithm(Algorithm::NlmeansHq(HqParams {
560                auto_strength: true,
561                noise_floor: true,
562                sigma_override: None,
563                temporal_confidence: true,
564                thsad_scale: 1.0,
565                sigma_scale: 1.0,
566            }))
567            .build();
568        let params = opts.to_nlm_params();
569
570        let expected = hq_default_strength(ChannelMode::Yuv, 0);
571        assert!((params.strength - expected).abs() < f32::EPSILON);
572    }
573
574    #[test]
575    fn hq_no_auto_strength_falls_back_to_the_legacy_absolute_default() {
576        // `effective_strength_with` only treats `strength` as a multiplier
577        // on the measured sigma when `auto_strength` is true. With it
578        // false, `strength` is used verbatim as an FFmpeg-style absolute
579        // strength, so the fallback must be the fast path's absolute
580        // default, not a calibrated multiplier from `hq_default_strength`.
581        let opts = DenoiserOptions::builder()
582            .algorithm(Algorithm::NlmeansHq(HqParams {
583                auto_strength: false,
584                noise_floor: true,
585                sigma_override: None,
586                temporal_confidence: true,
587                thsad_scale: 1.0,
588                sigma_scale: 1.0,
589            }))
590            .build();
591        let params = opts.to_nlm_params();
592
593        let expected = NlmParams::default().strength;
594        assert!(
595            (params.strength - expected).abs() < f32::EPSILON,
596            "expected the legacy absolute default {expected}, got {} (looks like the \
597             auto-strength multiplier table leaked through)",
598            params.strength
599        );
600    }
601
602    #[test]
603    fn hq_luma_r4_uses_measured_table_value() {
604        let opts = DenoiserOptions::builder()
605            .channel_mode(ChannelMode::Luma)
606            .mode(DenoisingMode::Temporal { radius: 4 })
607            .algorithm(Algorithm::NlmeansHq(HqParams::default()))
608            .build();
609        let params = opts.to_nlm_params();
610
611        assert!((params.strength - 0.35).abs() < f32::EPSILON);
612    }
613
614    #[test]
615    fn hq_chroma_r4_uses_measured_table_value() {
616        let opts = DenoiserOptions::builder()
617            .channel_mode(ChannelMode::Chroma)
618            .mode(DenoisingMode::Temporal { radius: 4 })
619            .algorithm(Algorithm::NlmeansHq(HqParams::default()))
620            .build();
621        let params = opts.to_nlm_params();
622
623        assert!((params.strength - 0.70).abs() < f32::EPSILON);
624    }
625
626    #[test]
627    fn hq_yuv_r8_uses_measured_table_value() {
628        let opts = DenoiserOptions::builder()
629            .channel_mode(ChannelMode::Yuv)
630            .mode(DenoisingMode::Temporal { radius: 8 })
631            .algorithm(Algorithm::NlmeansHq(HqParams::default()))
632            .build();
633        let params = opts.to_nlm_params();
634
635        assert!((params.strength - 0.30).abs() < f32::EPSILON);
636    }
637
638    #[test]
639    fn hq_spacial_mode_uses_radius_zero_table_values() {
640        for channels in [ChannelMode::Luma, ChannelMode::Chroma, ChannelMode::Yuv] {
641            let opts = DenoiserOptions::builder()
642                .channel_mode(channels)
643                .mode(DenoisingMode::Spacial)
644                .algorithm(Algorithm::NlmeansHq(HqParams::default()))
645                .build();
646            let params = opts.to_nlm_params();
647
648            let expected = hq_default_strength(channels, 0);
649            assert!(
650                (params.strength - expected).abs() < f32::EPSILON,
651                "channels={channels:?}: expected {expected}, got {}",
652                params.strength
653            );
654        }
655    }
656
657    #[test]
658    fn hq_explicit_strength_wins_over_the_table_for_every_plane() {
659        for channels in [ChannelMode::Luma, ChannelMode::Chroma, ChannelMode::Yuv] {
660            let opts = DenoiserOptions::builder()
661                .channel_mode(channels)
662                .mode(DenoisingMode::Temporal { radius: 4 })
663                .algorithm(Algorithm::NlmeansHq(HqParams::default()))
664                .nlm(NlmTuning {
665                    search_radius: None,
666                    patch_radius: None,
667                    strength: Some(0.99),
668                    self_weight: None,
669                })
670                .build();
671            let params = opts.to_nlm_params();
672
673            assert!(
674                (params.strength - 0.99).abs() < f32::EPSILON,
675                "channels={channels:?}: explicit strength was overridden by the table"
676            );
677        }
678    }
679
680    #[test]
681    fn hq_explicit_strength_is_respected() {
682        let opts = DenoiserOptions::builder()
683            .algorithm(Algorithm::NlmeansHq(HqParams {
684                auto_strength: true,
685                noise_floor: true,
686                sigma_override: None,
687                temporal_confidence: true,
688                thsad_scale: 1.0,
689                sigma_scale: 1.0,
690            }))
691            .nlm(NlmTuning {
692                search_radius: None,
693                patch_radius: None,
694                strength: Some(1.0),
695                self_weight: None,
696            })
697            .build();
698        let params = opts.to_nlm_params();
699
700        assert!((params.strength - 1.0).abs() < f32::EPSILON);
701    }
702
703    #[test]
704    fn fast_unset_strength_defaults_to_legacy_default() {
705        let opts = DenoiserOptions::builder().algorithm(Algorithm::Nlmeans).build();
706        let params = opts.to_nlm_params();
707
708        assert!((params.strength - 1.2).abs() < f32::EPSILON);
709    }
710
711    #[test]
712    fn motion_compensation_passthrough() {
713        let opts = DenoiserOptions::builder()
714            .mode(DenoisingMode::Temporal { radius: 1 })
715            .motion_compensation(MotionCompensationMode::Mvtools {
716                blksize: 16,
717                overlap: 8,
718                search_radius: 4,
719                pyramid_levels: 2,
720                estimation: MotionEstimation::Direct,
721            })
722            .build();
723        let params = opts.to_nlm_params();
724
725        assert!(matches!(
726            params.motion_compensation,
727            MotionCompensationMode::Mvtools {
728                blksize: 16,
729                overlap: 8,
730                search_radius: 4,
731                pyramid_levels: 2,
732                ..
733            }
734        ));
735    }
736
737    #[test]
738    fn motion_compensation_defaults_to_none() {
739        let opts = DenoiserOptions::builder().build();
740        let params = opts.to_nlm_params();
741        assert!(matches!(params.motion_compensation, MotionCompensationMode::None));
742    }
743
744    #[test]
745    fn nlm_tuning_overrides_individual_fields() {
746        let defaults = NlmParams::default();
747        let opts = DenoiserOptions::builder()
748            .nlm(NlmTuning {
749                search_radius: Some(7),
750                patch_radius: None,
751                strength: Some(2.5),
752                self_weight: None,
753            })
754            .build();
755        let params = opts.to_nlm_params();
756
757        assert_eq!(params.search_radius, 7);
758        assert_eq!(params.patch_radius, defaults.patch_radius);
759        assert!((params.strength - 2.5).abs() < f32::EPSILON);
760        assert!((params.self_weight - defaults.self_weight).abs() < f32::EPSILON);
761    }
762}
763
764#[cfg(all(test, feature = "vulkan"))]
765mod tests {
766    use super::*;
767
768    fn opts(mode: DenoisingMode) -> DenoiserOptions {
769        DenoiserOptions::builder()
770            .channel_mode(ChannelMode::Luma)
771            .mode(mode)
772            .build()
773    }
774
775    fn frame(w: u32, h: u32) -> Vec<f32> {
776        vec![0.5f32; (w * h) as usize]
777    }
778
779    #[test]
780    fn spatial_denoise_roundtrip() {
781        let mut d = Denoiser::create(
782            &[Accelerator::Vulkan],
783            &Device::Default,
784            16,
785            16,
786            opts(DenoisingMode::Spacial),
787        )
788        .expect("denoiser construction failed");
789        assert_eq!(d.selected_accelerator(), Accelerator::Vulkan);
790
791        d.push_frame(&frame(16, 16)).expect("push failed");
792        let out = d.recv_frame().expect("recv failed").expect("no frame");
793        assert_eq!(out.len(), 16 * 16);
794    }
795
796    #[test]
797    fn invalid_params_surface_as_error() {
798        let bad = DenoiserOptions::builder()
799            .nlm(NlmTuning {
800                search_radius: None,
801                patch_radius: None,
802                strength: Some(0.0),
803                self_weight: None,
804            })
805            .build();
806        let result = Denoiser::create(&[Accelerator::Vulkan], &Device::Default, 16, 16, bad);
807
808        match result {
809            Err(DenoiserError::Other(_)) => {},
810            Err(other) => panic!("expected DenoiserError::Other, got {other:?}"),
811            Ok(_) => panic!("expected validation error, got Ok"),
812        }
813    }
814
815    #[test]
816    fn tiny_frame_dimensions_surface_as_error() {
817        let result = Denoiser::create(
818            &[Accelerator::Vulkan],
819            &Device::Default,
820            2,
821            2,
822            opts(DenoisingMode::Spacial),
823        );
824
825        match result {
826            Err(DenoiserError::Other(e)) => {
827                assert!(
828                    e.to_string().contains("supported minimum"),
829                    "unexpected error message: {e}"
830                );
831            },
832            Err(other) => panic!("expected DenoiserError::Other, got {other:?}"),
833            Ok(_) => panic!("expected dimension validation error, got Ok"),
834        }
835    }
836
837    #[test]
838    fn push_after_pending_returns_queue_full() {
839        let mut d = Denoiser::create(
840            &[Accelerator::Vulkan],
841            &Device::Default,
842            16,
843            16,
844            opts(DenoisingMode::Spacial),
845        )
846        .unwrap();
847
848        // Depth-2 pipeline: the first two pushes both submit successfully
849        // (output handles are double-buffered). The third would alias the
850        // oldest pending's output slot and is rejected with QueueFull.
851        d.push_frame(&frame(16, 16)).unwrap();
852        d.push_frame(&frame(16, 16)).unwrap();
853        let err = d.push_frame(&frame(16, 16)).expect_err("expected QueueFull");
854        assert!(matches!(err, DenoiserError::QueueFull));
855
856        let out = d.recv_frame().unwrap().unwrap();
857        assert_eq!(out.len(), 16 * 16);
858
859        // After draining one slot the next push must succeed.
860        d.push_frame(&frame(16, 16)).expect("push after drain failed");
861    }
862
863    fn frame_filled(w: u32, h: u32, value: f32) -> Vec<f32> {
864        vec![value; (w * h) as usize]
865    }
866
867    /// Push `n` frames of the given value, interleaving `recv_frame` to keep
868    /// the in-flight pipeline below `MAX_PENDING`.
869    fn push_n_with_drain(d: &mut Denoiser, n: usize, value: f32, out: &mut Vec<Vec<f32>>) {
870        for _ in 0..n {
871            loop {
872                match d.push_frame(&frame_filled(16, 16, value)) {
873                    Ok(()) => break,
874                    Err(DenoiserError::QueueFull) => {
875                        let f = d
876                            .recv_frame()
877                            .expect("recv ok")
878                            .expect("queue full but recv yielded none");
879                        out.push(f);
880                    },
881                    Err(e) => panic!("unexpected push error: {e:?}"),
882                }
883            }
884        }
885    }
886
887    #[test]
888    fn flush_leaves_denoiser_reusable_spatial() {
889        let mut d = Denoiser::create(
890            &[Accelerator::Vulkan],
891            &Device::Default,
892            16,
893            16,
894            opts(DenoisingMode::Spacial),
895        )
896        .unwrap();
897
898        let mut batch_a = Vec::new();
899        push_n_with_drain(&mut d, 5, 0.25, &mut batch_a);
900        d.flush(|f| batch_a.push(f)).expect("first flush failed");
901        assert_eq!(batch_a.len(), 5);
902
903        // After flush the pipeline must be empty.
904        assert!(d.recv_frame().unwrap().is_none());
905
906        let mut batch_b = Vec::new();
907        push_n_with_drain(&mut d, 5, 0.75, &mut batch_b);
908        d.flush(|f| batch_b.push(f)).expect("second flush failed");
909        assert_eq!(batch_b.len(), 5);
910
911        for v in batch_b.iter().flatten() {
912            assert!((v - 0.75).abs() < 0.1, "batch_b carried state from batch_a: {v}");
913        }
914        for v in batch_a.iter().flatten() {
915            assert!((v - 0.25).abs() < 0.1, "batch_a value unexpectedly drifted: {v}");
916        }
917    }
918
919    #[test]
920    fn flush_leaves_denoiser_reusable_temporal() {
921        let mut d = Denoiser::create(
922            &[Accelerator::Vulkan],
923            &Device::Default,
924            16,
925            16,
926            opts(DenoisingMode::Temporal { radius: 1 }),
927        )
928        .unwrap();
929
930        let mut batch_a = Vec::new();
931        push_n_with_drain(&mut d, 5, 0.25, &mut batch_a);
932        d.flush(|f| batch_a.push(f)).expect("first flush failed");
933        assert_eq!(batch_a.len(), 5, "expected 5 frames from first batch");
934
935        // The temporal window must be empty after flush: the first push of
936        // the new stream should not yield a pending immediately. With r=1
937        // the window needs 3 frames before `denoise_submit` fires.
938        assert!(d.recv_frame().unwrap().is_none());
939        d.push_frame(&frame_filled(16, 16, 0.75)).unwrap();
940        assert!(
941            d.recv_frame().unwrap().is_none(),
942            "first push of new temporal stream should not produce output yet"
943        );
944
945        // Push 4 more frames (5 total in batch B) with drain.
946        let mut batch_b = Vec::new();
947        push_n_with_drain(&mut d, 4, 0.75, &mut batch_b);
948        d.flush(|f| batch_b.push(f)).expect("second flush failed");
949        assert_eq!(batch_b.len(), 5, "expected 5 frames from second batch");
950
951        for v in batch_b.iter().flatten() {
952            assert!((v - 0.75).abs() < 0.1, "batch_b carried state from batch_a: {v}");
953        }
954    }
955
956    #[test]
957    fn flush_emits_exactly_n_outputs_for_small_n() {
958        // With temporal radius R=2 the window is 5 frames. Pushing fewer
959        // than R+1 frames means the window never fills during pushes —
960        // flush must still emit exactly N outputs (one per pushed frame),
961        // not R+1.
962        for n in 1..=5usize {
963            let mut d = Denoiser::create(
964                &[Accelerator::Vulkan],
965                &Device::Default,
966                16,
967                16,
968                opts(DenoisingMode::Temporal { radius: 2 }),
969            )
970            .unwrap();
971
972            let mut out = Vec::new();
973            push_n_with_drain(&mut d, n, 0.5, &mut out);
974            d.flush(|f| out.push(f)).expect("flush failed");
975            assert_eq!(
976                out.len(),
977                n,
978                "expected {n} outputs for {n} pushes, got {}",
979                out.len()
980            );
981        }
982    }
983}
984
985// Used to just catch fires on the CPU backend
986#[cfg(all(test, feature = "cpu"))]
987mod cpu_smoke_tests {
988    use super::*;
989
990    #[test]
991    fn cpu_backend_denoises_a_frame() {
992        let opts = DenoiserOptions::builder()
993            .channel_mode(ChannelMode::Luma)
994            .mode(DenoisingMode::Spacial)
995            .build();
996        let mut d = Denoiser::create(&[Accelerator::Cpu], &Device::Default, 16, 16, opts)
997            .expect("denoiser construction failed");
998        assert_eq!(d.selected_accelerator(), Accelerator::Cpu);
999
1000        d.push_frame(&vec![0.5f32; 16 * 16]).expect("push failed");
1001        let out = d.recv_frame().expect("recv failed").expect("no frame");
1002        assert_eq!(out.len(), 16 * 16);
1003    }
1004}