Skip to main content

av_denoise_core/
denoiser.rs

1use std::collections::VecDeque;
2
3use cubecl::Runtime;
4use cubecl::prelude::ComputeClient;
5
6use crate::accelerate::Accelerator;
7use crate::device::Device;
8use crate::nl4d::{Nl4dDenoiser, Nl4dParams};
9#[cfg(test)]
10use crate::nlmeans::MotionEstimation;
11use crate::nlmeans::{
12    ChannelMode,
13    Depth,
14    HqParams,
15    MotionCompensationMode,
16    MotionSearch,
17    NlmDenoiser,
18    NlmParams,
19    Pending,
20    PrefilterMode,
21    TryWait,
22    hq_default_strength,
23    validate_dimensions,
24};
25use crate::sniff::sniff_best_accelerator;
26
27/// How a [`Denoiser`] should be set up.
28///
29/// Build one with `DenoiserOptions::builder()`. Every field has a
30/// default, so only the parts you care about need naming.
31///
32/// Only the settings every algorithm reads live here. Everything else
33/// belongs to whichever [`Algorithm`] variant actually uses it.
34#[derive(Debug, Clone, bon::Builder)]
35pub struct DenoiserOptions {
36    /// Which channels of the frame to denoise.
37    #[builder(default = ChannelMode::Yuv)]
38    pub channel_mode: ChannelMode,
39    /// Whether to clean each frame on its own or across a temporal
40    /// window.
41    #[builder(default = DenoisingMode::Spacial)]
42    pub mode: DenoisingMode,
43    /// Which algorithm to run, along with the settings only that
44    /// algorithm reads.
45    #[builder(default)]
46    pub algorithm: Algorithm,
47    /// What format denoised frames come back in.
48    #[builder(default = OutputFormat::F32)]
49    pub output_format: OutputFormat,
50}
51
52/// What a denoiser hands back from [`Denoiser::recv_frame`],
53/// [`Denoiser::try_recv_frame`] and [`Denoiser::flush`].
54///
55/// The GPU only ever holds normalised `f32`. [`Depth`] is a wire concept,
56/// so a denoiser that returns wire bytes is told its depth when it is
57/// built rather than at each call.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum OutputFormat {
60    /// Normalised `f32`, one value per channel per pixel.
61    F32,
62    /// Wire bytes at this depth, quantised on the GPU.
63    Wire { depth: Depth },
64}
65
66/// One denoised frame, in whichever format its denoiser was built for.
67#[derive(Debug, Clone, PartialEq)]
68pub enum FrameOutput {
69    /// Normalised `f32`, `width * height * channels` values.
70    F32(Vec<f32>),
71    /// Wire bytes, interleaved except for a chroma pair, which is laid
72    /// out as U's whole region followed by V's.
73    Wire(Vec<u8>),
74}
75
76impl FrameOutput {
77    /// The `f32` frame, or `None` if this came from a wire-mode denoiser.
78    pub fn into_f32(self) -> Option<Vec<f32>> {
79        match self {
80            Self::F32(v) => Some(v),
81            Self::Wire(_) => None,
82        }
83    }
84
85    /// The wire bytes, or `None` if this came from an `f32`-mode denoiser.
86    pub fn into_wire(self) -> Option<Vec<u8>> {
87        match self {
88            Self::Wire(v) => Some(v),
89            Self::F32(_) => None,
90        }
91    }
92
93    /// Borrows the `f32` frame, or `None` if this came from a wire-mode
94    /// denoiser.
95    pub fn as_f32(&self) -> Option<&[f32]> {
96        match self {
97            Self::F32(v) => Some(v),
98            Self::Wire(_) => None,
99        }
100    }
101
102    /// Borrows the wire bytes, or `None` if this came from an `f32`-mode
103    /// denoiser.
104    pub fn as_wire(&self) -> Option<&[u8]> {
105        match self {
106            Self::Wire(v) => Some(v),
107            Self::F32(_) => None,
108        }
109    }
110}
111
112/// Which denoising algorithm to run.
113///
114/// Each variant carries its own settings, so a knob one algorithm has no
115/// use for cannot be set on it.
116#[derive(Debug, Copy, Clone, PartialEq)]
117pub enum Algorithm {
118    /// The fast NLMeans path, with fixed weighting and no noise
119    /// measurement.
120    Nlmeans(NlmeansOptions),
121    /// NLMeans with its weighting matched to the measured noise level.
122    ///
123    /// This also uses a different default `strength`, one that adapts to
124    /// the temporal radius and the plane being denoised. See
125    /// [`crate::nlmeans::hq_default_strength`].
126    NlmeansHq(NlmeansHqOptions),
127    /// Groups 8x8 patches across the motion-compensated temporal window
128    /// itself, rather than filtering with NLM first and grouping within
129    /// one frame afterward.
130    ///
131    /// No NLM weighting pass ever runs, so none of the NLM knobs appear
132    /// on [`Nl4dOptions`].
133    Nl4d(Nl4dOptions),
134}
135
136impl Default for Algorithm {
137    fn default() -> Self {
138        Self::Nlmeans(NlmeansOptions::default())
139    }
140}
141
142/// Settings for [`Algorithm::Nlmeans`].
143#[derive(Debug, Copy, Clone, Default, PartialEq)]
144pub struct NlmeansOptions {
145    /// Which reference image the NLM weights are computed against.
146    ///
147    /// `None`, the default, compares patches on the noisy input
148    /// directly. Every other mode costs one extra GPU pass per frame.
149    pub prefilter: PrefilterMode,
150    /// Whether temporal denoising follows motion between frames.
151    ///
152    /// `None`, the default, turns motion compensation off. `Mvtools`
153    /// warps temporal neighbours into line with the centre frame before
154    /// the NLM weighting runs.
155    ///
156    /// Only has an effect when [`DenoiserOptions::mode`] is
157    /// `Temporal { .. }`.
158    pub motion_compensation: MotionCompensationMode,
159    /// Overrides for the NLM search radius, patch radius, strength, and
160    /// self-weight.
161    pub tuning: NlmTuning,
162}
163
164/// Settings for [`Algorithm::NlmeansHq`].
165#[derive(Debug, Copy, Clone, Default, PartialEq)]
166pub struct NlmeansHqOptions {
167    /// Everything the fast path takes, which HQ takes too.
168    pub nlm: NlmeansOptions,
169    /// The noise measurement and confidence weighting HQ adds on top.
170    pub hq: HqParams,
171}
172
173/// Settings for [`Algorithm::Nl4d`].
174///
175/// nl4d runs the HQ front end only for its machinery, the frame ring,
176/// the motion field, and the noise estimate. Nothing weights or averages
177/// patches the NLM way, so the NLM knobs are absent here and the fields
178/// below are the whole surface.
179///
180/// The temporal radius comes from [`DenoiserOptions::mode`], which has to
181/// be `Temporal { .. }`. Motion tracking is always on, because the
182/// grouping kernel reads the motion field and confidence scores it
183/// produces.
184///
185/// `lambda_ht` has a per-plane default. `None` resolves through
186/// [`nl4d_default_lambda_ht`] once the plane being denoised is known.
187/// `lambda_ht_scale` then multiplies whichever value that resolves to.
188///
189/// Every other default comes from [`Nl4dParams::default`].
190#[derive(Debug, Copy, Clone, PartialEq)]
191pub struct Nl4dOptions {
192    /// How motion between frames is tracked.
193    pub motion: MotionSearch,
194    /// A fixed noise standard deviation in `[0, 1]` units, replacing the
195    /// automatic per-frame estimate.
196    ///
197    /// `None`, the default, measures the noise in each pushed frame and
198    /// smooths it over time.
199    pub sigma: Option<f32>,
200    /// A multiplier applied to the measured noise level before anything
201    /// reads it. Defaults to 1.0.
202    ///
203    /// This does nothing when `sigma` pins the noise level, because the
204    /// estimator never runs in that case.
205    pub sigma_scale: f32,
206    /// A multiplier on the per-block mismatch threshold, which sets how
207    /// much extra SAD a block tolerates before its confidence starts to
208    /// fall. Defaults to 1.0.
209    ///
210    /// Higher values tolerate larger mismatches.
211    pub thsad_scale: f32,
212    /// Half-width of the refine window searched around each neighbour
213    /// frame's motion-predicted position, in `1..=4`. Defaults to 2.
214    pub refine: u32,
215    /// Half-width of the spatial candidate window searched in the centre
216    /// frame, in `1..=16`. Defaults to 9.
217    pub spatial_radius: u32,
218    /// Hard-threshold multiplier on the propagated coefficient sigma.
219    /// Higher removes more noise and more fine detail.
220    ///
221    /// `None` resolves through [`nl4d_default_lambda_ht`], which returns
222    /// a different value for luma than for chroma.
223    pub lambda_ht: Option<f32>,
224    /// A multiplier applied to the resolved `lambda_ht`. Defaults to
225    /// 1.0.
226    ///
227    /// It scales an explicit `lambda_ht` and the calibrated per-plane
228    /// default alike, so one value moves both planes together. Has to
229    /// be finite and in `[0.1, 10.0]`.
230    pub lambda_ht_scale: f32,
231    /// The confidence floor below which a whole neighbour block is
232    /// skipped rather than scored, in `[0, 1)`. Defaults to 0.05. Only
233    /// affects how much compute a submit spends, never which candidates
234    /// are admitted once they are scored.
235    pub c_min: f32,
236    /// A multiplier on the mismatch variance a poorly matched temporal
237    /// member carries into the hard threshold. Defaults to 1.0.
238    ///
239    /// The variance grows with the square of this. The mechanism
240    /// saturates well before the top of its accepted range, see
241    /// [`crate::nl4d::Nl4dParams::mismatch_scale`].
242    pub mismatch_scale: f32,
243    /// Whether a temporal member's mismatch variance reaches the
244    /// hard-threshold shrinkage at all. Defaults to `true`. See
245    /// [`crate::nl4d::Nl4dParams::confidence_variance`].
246    pub confidence_variance: bool,
247    /// The `beta` of the Kaiser window each filtered patch is tapered
248    /// with as it is aggregated. Defaults to 2.0. `0.0` is uniform
249    /// aggregation. See [`crate::nl4d::Nl4dParams::kaiser_beta`].
250    pub kaiser_beta: f32,
251    /// Estimates noise fresh from each frame's own window instead of
252    /// smoothing it across the whole stream's history. Defaults to
253    /// `false`, matching every calibrated preset.
254    ///
255    /// `av-denoise-vs` turns this on unconditionally, because a
256    /// VapourSynth filter has to return the same pixels for a frame no
257    /// matter what order frames were requested in, and history-dependent
258    /// estimation breaks that guarantee under random access. See
259    /// [`HqParams::windowed_noise_estimation`].
260    pub windowed_noise_estimation: bool,
261    /// See [`crate::nl4d::Nl4dParams::field_lambda`].
262    pub field_lambda: f32,
263}
264
265impl Default for Nl4dOptions {
266    fn default() -> Self {
267        let defaults = Nl4dParams::default();
268        let hq = HqParams::default();
269        Self {
270            motion: MotionSearch::default(),
271            sigma: hq.sigma_override,
272            sigma_scale: hq.sigma_scale,
273            thsad_scale: hq.thsad_scale,
274            refine: defaults.refine,
275            spatial_radius: defaults.spatial_radius,
276            // Resolved per plane by `nl4d_default_lambda_ht` at
277            // construction time, once the plane being denoised is
278            // known.
279            lambda_ht: None,
280            lambda_ht_scale: 1.0,
281            c_min: defaults.c_min,
282            mismatch_scale: defaults.mismatch_scale,
283            confidence_variance: defaults.confidence_variance,
284            kaiser_beta: defaults.kaiser_beta,
285            windowed_noise_estimation: false,
286            field_lambda: defaults.field_lambda,
287        }
288    }
289}
290
291impl Nl4dOptions {
292    /// The front end's HQ parameters for this configuration.
293    ///
294    /// `temporal_confidence` is always on, because the grouping kernel
295    /// reads the confidence scores it produces. The two strength-related
296    /// switches keep their defaults, since nl4d never runs a weighting
297    /// pass for them to affect.
298    fn to_hq_params(self) -> HqParams {
299        HqParams {
300            sigma_override: self.sigma,
301            sigma_scale: self.sigma_scale,
302            thsad_scale: self.thsad_scale,
303            temporal_confidence: true,
304            windowed_noise_estimation: self.windowed_noise_estimation,
305            ..HqParams::default()
306        }
307    }
308}
309
310/// The default `lambda_ht` for nl4d's hard-threshold stage, per plane.
311///
312/// `lambda_ht` is how many standard deviations of estimated noise a
313/// transform coefficient has to clear to survive. Raising it removes more
314/// noise and more fine detail with it, so the value is a trade rather
315/// than an optimum.
316///
317/// Luma and chroma values are picked by eye from a ladder of renders against real
318/// film grain, accepting more lost detail in exchange for less remaining
319/// noise on heavy grain. Separately confirmed not to over-filter near-clean
320/// animation. The reason why we're going a bit heavier on high noise is because
321/// the encoders end up reducing that detail _more_ than the denoiser does if
322/// that extra entropy remains in and overall produces a worse final image.
323///
324/// `ChannelMode::Yuv` reads the luma value, on the same "a fused pass is
325/// dominated by luma" assumption [`hq_default_strength`]
326/// makes for its own Yuv case.
327///
328/// Luma and the fused Yuv mode use 5.2, and chroma uses 3.4.
329pub fn nl4d_default_lambda_ht(channels: ChannelMode) -> f32 {
330    match channels {
331        ChannelMode::Luma | ChannelMode::Yuv => 5.2,
332        ChannelMode::Chroma => 3.4,
333    }
334}
335
336/// Resolves `Nl4dOptions.lambda_ht` for one plane, falling back to
337/// [`nl4d_default_lambda_ht`] when the caller left it unset, then
338/// applies `lambda_ht_scale`.
339///
340/// The scale multiplies an explicit value and the calibrated default
341/// alike, so it moves both planes together whether or not one of them
342/// is pinned.
343///
344/// The range check lives here rather than in [`Nl4dParams`],
345/// which only ever sees the product. A scale of 0 would surface there as
346/// a complaint about `lambda_ht`, naming a knob the caller never set.
347fn resolve_lambda_ht(opts: &Nl4dOptions, channels: ChannelMode) -> Result<f32, String> {
348    if !(opts.lambda_ht_scale.is_finite() && (0.1..=10.0).contains(&opts.lambda_ht_scale)) {
349        return Err(format!(
350            "lambda_ht_scale must be finite and in [0.1, 10.0], got {}",
351            opts.lambda_ht_scale
352        ));
353    }
354
355    let lambda_ht = opts.lambda_ht.unwrap_or_else(|| nl4d_default_lambda_ht(channels));
356
357    Ok(lambda_ht * opts.lambda_ht_scale)
358}
359
360/// Speed vs quality dial.
361///
362/// Each denoising family reads the same dial and fills in its own knobs
363/// from it. For `nlmeans` that is [`nlmeans_variant_for`],
364/// [`nlmeans_temporal_radius_for`], and [`nlmeans_search_radius_for`].
365/// For `nl4d` it is [`nl4d_temporal_radius_for`] and
366/// [`nl4d_spatial_radius_for`].
367///
368/// Both front ends parse the same names from this one type, so a preset
369/// resolves to the same dials everywhere it is used.
370#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, strum_macros::EnumString)]
371#[strum(ascii_case_insensitive)]
372pub enum Preset {
373    /// Fastest and lowest quality.
374    Veryfast,
375    /// One step up from `veryfast`.
376    Fast,
377    /// The default, favouring quality over speed.
378    #[default]
379    Base,
380    /// One step down from `veryslow`.
381    Slow,
382    /// Slowest and highest quality.
383    Veryslow,
384}
385
386/// Which nlmeans implementation a preset, or an explicit choice, selects.
387#[derive(Debug, Copy, Clone, PartialEq, Eq, strum_macros::EnumString)]
388#[strum(ascii_case_insensitive)]
389pub enum NlmeansVariant {
390    /// The fast path. Fixed weighting, no noise measurement.
391    Fast,
392    /// Quality focused. Calibrates its weighting to the noise level,
393    /// measured automatically per frame.
394    Hq,
395}
396
397/// Which [`NlmeansVariant`] a preset runs.
398pub fn nlmeans_variant_for(preset: Preset) -> NlmeansVariant {
399    match preset {
400        Preset::Veryfast => NlmeansVariant::Fast,
401        Preset::Fast | Preset::Base | Preset::Slow | Preset::Veryslow => NlmeansVariant::Hq,
402    }
403}
404
405/// How many neighbouring frames on each side `nlmeans` looks at, at a
406/// preset.
407pub fn nlmeans_temporal_radius_for(preset: Preset) -> u32 {
408    match preset {
409        Preset::Veryfast => 0,
410        Preset::Fast => 1,
411        Preset::Base => 2,
412        Preset::Slow => 4,
413        Preset::Veryslow => 8,
414    }
415}
416
417/// How far `nlmeans` looks for similar patches inside a frame, at a
418/// preset.
419pub fn nlmeans_search_radius_for(preset: Preset) -> u32 {
420    match preset {
421        Preset::Veryfast | Preset::Fast | Preset::Base => 2,
422        Preset::Slow | Preset::Veryslow => 4,
423    }
424}
425
426/// How far the temporal window reaches at each preset, for `nl4d`.
427///
428/// Unlike `nlmeans`, `veryfast` keeps a 1-frame window rather than
429/// dropping to 0, because nl4d has nothing to do without neighbouring
430/// frames to group against.
431pub fn nl4d_temporal_radius_for(preset: Preset) -> u32 {
432    match preset {
433        Preset::Veryfast | Preset::Fast => 1,
434        Preset::Base => 2,
435        Preset::Slow => 4,
436        Preset::Veryslow => 8,
437    }
438}
439
440/// How wide the centre frame's candidate search is at each preset, for
441/// `nl4d`.
442///
443/// `veryfast` shares its temporal radius with `fast`, so this is what
444/// separates them. The window covers `(2 * radius + 1)^2` positions, so
445/// 6 searches a little over half the candidates 9 does.
446///
447/// Every preset from `fast` up uses the library default. Widening it
448/// further at the slow end costs quadratically and has not been measured
449/// to be worth it.
450pub fn nl4d_spatial_radius_for(preset: Preset) -> u32 {
451    match preset {
452        Preset::Veryfast => 6,
453        Preset::Fast | Preset::Base | Preset::Slow | Preset::Veryslow => {
454            Nl4dOptions::default().spatial_radius
455        },
456    }
457}
458
459/// Whether a frame is cleaned on its own or alongside its neighbours.
460#[derive(Debug, Copy, Clone, Eq, PartialEq)]
461pub enum DenoisingMode {
462    /// Cleans each frame using only its own pixels.
463    Spacial,
464    /// Cleans each frame using a window of `2 * radius + 1` frames.
465    Temporal { radius: u32 },
466}
467
468/// NLM tuning knobs.
469///
470/// Every field is optional. Whatever is left unset falls back to the
471/// library default.
472#[derive(Debug, Copy, Clone, Default, PartialEq)]
473pub struct NlmTuning {
474    pub search_radius: Option<u32>,
475    pub patch_radius: Option<u32>,
476    pub strength: Option<f32>,
477    pub self_weight: Option<f32>,
478}
479
480impl DenoiserOptions {
481    /// Turns this option set into the low-level [`NlmParams`] a backend
482    /// denoiser is built from.
483    ///
484    /// For nl4d this describes the front end only, since nl4d's own
485    /// grouping stage is configured from [`Nl4dOptions`] separately in
486    /// [`build_engine`].
487    ///
488    /// Whichever default `strength` applies is folded in here. For the
489    /// HQ algorithm that comes from
490    /// [`crate::nlmeans::hq_default_strength`].
491    ///
492    /// This is public so callers building per-plane options, and tests,
493    /// can read the resolved values without building a real `Denoiser`.
494    #[doc(hidden)]
495    pub fn to_nlm_params(&self) -> NlmParams {
496        let temporal_radius = match self.mode {
497            DenoisingMode::Spacial => 0,
498            DenoisingMode::Temporal { radius } => radius,
499        };
500
501        match self.algorithm {
502            Algorithm::Nlmeans(opts) => self.nlm_params_for(opts, None, temporal_radius),
503            Algorithm::NlmeansHq(opts) => self.nlm_params_for(opts.nlm, Some(opts.hq), temporal_radius),
504            // nl4d never runs a weighting pass, so `strength`,
505            // `search_radius`, `patch_radius`, and `self_weight` stay at
506            // their library defaults and no prefilter is built.
507            Algorithm::Nl4d(opts) => NlmParams {
508                channels: self.channel_mode,
509                motion_compensation: opts.motion.into(),
510                temporal_radius,
511                hq: Some(opts.to_hq_params()),
512                ..NlmParams::default()
513            },
514        }
515    }
516
517    /// [`Self::to_nlm_params`] for whichever of the two NLM algorithms
518    /// is running, with `hq` set only for the quality one.
519    fn nlm_params_for(&self, opts: NlmeansOptions, hq: Option<HqParams>, temporal_radius: u32) -> NlmParams {
520        // An explicit `strength` always wins, whether it came straight
521        // from `NlmTuning` or from a per-plane override the caller
522        // already folded in.
523        //
524        // Otherwise the default depends on `auto_strength`. With it on,
525        // HQ reads `strength` as a multiplier on the measured noise
526        // level, so it needs its own calibrated default rather than the
527        // fast path's absolute FFmpeg-style one. That calibrated default
528        // also varies with the temporal radius and with the plane
529        // `channel_mode` names, because each per-plane `Denoiser`
530        // carries its own channel mode.
531        //
532        // With auto-strength off, HQ reads `strength` as an absolute
533        // value just like the fast path, so it falls back to the same
534        // absolute default.
535        let strength = opts.tuning.strength.unwrap_or(match hq {
536            Some(hq) if hq.auto_strength => hq_default_strength(self.channel_mode, temporal_radius),
537            _ => NlmParams::default().strength,
538        });
539
540        let defaults = NlmParams::default();
541        NlmParams {
542            channels: self.channel_mode,
543            prefilter: opts.prefilter,
544            motion_compensation: opts.motion_compensation,
545            temporal_radius,
546            hq,
547            strength,
548            search_radius: opts.tuning.search_radius.unwrap_or(defaults.search_radius),
549            patch_radius: opts.tuning.patch_radius.unwrap_or(defaults.patch_radius),
550            self_weight: opts.tuning.self_weight.unwrap_or(defaults.self_weight),
551        }
552    }
553}
554
555/// Errors reported by the high-level [`Denoiser`].
556#[derive(Debug, thiserror::Error)]
557pub enum DenoiserError {
558    /// An earlier denoised frame has not been collected yet, so pushing
559    /// again would overwrite it in the double-buffered output slot.
560    ///
561    /// Call [`Denoiser::recv_frame`] or [`Denoiser::try_recv_frame`],
562    /// then retry the same `push_frame` call.
563    #[error("denoiser queue is full, collect the pending frame before pushing more")]
564    QueueFull,
565    /// An earlier call failed, so how many frames are in flight is no
566    /// longer known and later output would not line up with its input.
567    ///
568    /// Call [`Denoiser::reset_stream`] to start a fresh stream, or drop
569    /// the denoiser. Either one settles a frame that
570    /// [`Denoiser::try_recv_frame`] has already polled, so it can block
571    /// for that readback's remaining latency.
572    #[error("denoiser failed earlier, reset the stream before using it again")]
573    Poisoned,
574    /// None of the accelerators in the priority list could be started.
575    #[error("no accelerator from the priority list is available")]
576    NoAcceleratorAvailable,
577    /// Anything else, wrapping the internal `anyhow` errors raised by
578    /// kernel dispatch and readback.
579    #[error(transparent)]
580    Other(#[from] anyhow::Error),
581}
582
583/// Either denoiser a `Backend` runtime arm can hold.
584///
585/// This keeps `Backend`'s own match arms at one line each. Without it,
586/// adding a second denoiser type would multiply the runtime arms instead
587/// of fanning out once here.
588enum Engine<R: Runtime> {
589    Nlm(Box<NlmDenoiser<R>>),
590    Nl4d(Box<Nl4dDenoiser<R>>),
591}
592
593impl<R: Runtime> Engine<R> {
594    fn is_nl4d(&self) -> bool {
595        matches!(self, Self::Nl4d(_))
596    }
597
598    fn push_frame(&mut self, frame: &[f32]) {
599        match self {
600            Self::Nlm(d) => d.push_frame(frame),
601            Self::Nl4d(d) => d.push_frame(frame),
602        }
603    }
604
605    fn push_frame_wire(&mut self, planes: &[&[u8]], depth: Depth) {
606        match self {
607            Self::Nlm(d) => d.push_frame_wire(planes, depth),
608            Self::Nl4d(d) => d.push_frame_wire(planes, depth),
609        }
610    }
611
612    fn denoise_submit(&mut self) -> Result<Option<Pending<R>>, anyhow::Error> {
613        match self {
614            Self::Nlm(d) => d.denoise_submit(),
615            // `Nl4dDenoiser::denoise_submit` already returns
616            // `DenoiserError` rather than `anyhow::Error`, so this leans
617            // on `DenoiserError`'s own `anyhow::Error` conversion instead
618            // of re-wrapping it.
619            Self::Nl4d(d) => d.denoise_submit().map_err(anyhow::Error::from),
620        }
621    }
622
623    #[cfg(test)]
624    fn wire_outputs(&self) -> Option<&[cubecl::server::Handle; 2]> {
625        match self {
626            Self::Nlm(d) => d.wire_outputs_for_test(),
627            Self::Nl4d(d) => d.wire_outputs_for_test(),
628        }
629    }
630
631    fn flush(&mut self, sink: impl FnMut(&FrameOutput)) -> Result<(), anyhow::Error> {
632        match self {
633            Self::Nlm(d) => d.flush(sink),
634            Self::Nl4d(d) => d.flush(sink).map_err(anyhow::Error::from),
635        }
636    }
637
638    fn reset_stream(&mut self) {
639        match self {
640            Self::Nlm(d) => d.reset_stream_state(),
641            Self::Nl4d(d) => d.reset_stream(),
642        }
643    }
644}
645
646/// Builds whichever [`Engine`] `algorithm` calls for.
647///
648/// `Algorithm::Nl4d` carries its own grouping tuning, which is not part
649/// of `NlmParams`, so it is read from `algorithm` directly rather than
650/// from `params`. This is also where an unset `lambda_ht` picks up its
651/// calibrated per-plane default (`resolve_lambda_ht`), the same way
652/// `to_nlm_params` resolves HQ's calibrated `strength`, since this is
653/// the first point construction has both `opts` and `params.channels`
654/// together.
655fn build_engine<R: Runtime>(
656    client: &ComputeClient<R>,
657    algorithm: &Algorithm,
658    params: NlmParams,
659    width: u32,
660    height: u32,
661    output_format: OutputFormat,
662) -> Result<Engine<R>, DenoiserError> {
663    match algorithm {
664        Algorithm::Nl4d(opts) => {
665            // nl4d groups patches across neighbouring frames, so there
666            // is nothing for it to do without a temporal window.
667            if params.temporal_radius == 0 {
668                return Err(DenoiserError::Other(anyhow::anyhow!(
669                    "nl4d needs a temporal window, set DenoiserOptions::mode to \
670                     DenoisingMode::Temporal"
671                )));
672            }
673
674            let lambda_ht = resolve_lambda_ht(opts, params.channels)
675                .map_err(|e| DenoiserError::Other(anyhow::anyhow!(e)))?;
676            let nl4d_params = Nl4dParams {
677                temporal_radius: params.temporal_radius,
678                nlm: params,
679                refine: opts.refine,
680                spatial_radius: opts.spatial_radius,
681                lambda_ht,
682                c_min: opts.c_min,
683                mismatch_scale: opts.mismatch_scale,
684                confidence_variance: opts.confidence_variance,
685                kaiser_beta: opts.kaiser_beta,
686                field_lambda: opts.field_lambda,
687            };
688            let denoiser =
689                Nl4dDenoiser::with_output_format(client, nl4d_params, width, height, output_format)
690                    .map_err(|e| DenoiserError::Other(anyhow::anyhow!(e)))?;
691            Ok(Engine::Nl4d(Box::new(denoiser)))
692        },
693        Algorithm::Nlmeans(_) | Algorithm::NlmeansHq(_) => Ok(Engine::Nlm(Box::new(
694            NlmDenoiser::with_output_format(client, params, width, height, output_format),
695        ))),
696    }
697}
698
699enum Backend {
700    #[cfg(feature = "cuda")]
701    Cuda(Engine<cubecl::cuda::CudaRuntime>),
702    #[cfg(feature = "rocm")]
703    Rocm(Engine<cubecl::hip::HipRuntime>),
704    #[cfg(any(feature = "vulkan", feature = "metal"))]
705    Wgpu(Engine<cubecl::wgpu::WgpuRuntime>),
706}
707
708impl Backend {
709    fn is_nl4d(&self) -> bool {
710        match self {
711            #[cfg(feature = "cuda")]
712            Self::Cuda(e) => e.is_nl4d(),
713            #[cfg(feature = "rocm")]
714            Self::Rocm(e) => e.is_nl4d(),
715            #[cfg(any(feature = "vulkan", feature = "metal"))]
716            Self::Wgpu(e) => e.is_nl4d(),
717        }
718    }
719
720    #[cfg(test)]
721    fn wire_outputs(&self) -> Option<&[cubecl::server::Handle; 2]> {
722        match self {
723            #[cfg(feature = "cuda")]
724            Self::Cuda(e) => e.wire_outputs(),
725            #[cfg(feature = "rocm")]
726            Self::Rocm(e) => e.wire_outputs(),
727            #[cfg(any(feature = "vulkan", feature = "metal"))]
728            Self::Wgpu(e) => e.wire_outputs(),
729        }
730    }
731}
732
733enum BackendPending {
734    #[cfg(feature = "cuda")]
735    Cuda(Pending<cubecl::cuda::CudaRuntime>),
736    #[cfg(feature = "rocm")]
737    Rocm(Pending<cubecl::hip::HipRuntime>),
738    #[cfg(any(feature = "vulkan", feature = "metal"))]
739    Wgpu(Pending<cubecl::wgpu::WgpuRuntime>),
740}
741
742impl BackendPending {
743    fn wait(self) -> Result<FrameOutput, anyhow::Error> {
744        match self {
745            #[cfg(feature = "cuda")]
746            Self::Cuda(p) => p.wait(),
747            #[cfg(feature = "rocm")]
748            Self::Rocm(p) => p.wait(),
749            #[cfg(any(feature = "vulkan", feature = "metal"))]
750            Self::Wgpu(p) => p.wait(),
751        }
752    }
753
754    /// Polls the readback once. `Ok(Ok(frame))` is a landed frame,
755    /// `Ok(Err(self))` is a readback still in flight.
756    fn try_wait(self) -> Result<Result<FrameOutput, Self>, anyhow::Error> {
757        match self {
758            #[cfg(feature = "cuda")]
759            Self::Cuda(p) => match p.try_wait()? {
760                TryWait::Ready(frame) => Ok(Ok(frame)),
761                TryWait::NotReady(p) => Ok(Err(Self::Cuda(p))),
762            },
763            #[cfg(feature = "rocm")]
764            Self::Rocm(p) => match p.try_wait()? {
765                TryWait::Ready(frame) => Ok(Ok(frame)),
766                TryWait::NotReady(p) => Ok(Err(Self::Rocm(p))),
767            },
768            #[cfg(any(feature = "vulkan", feature = "metal"))]
769            Self::Wgpu(p) => match p.try_wait()? {
770                TryWait::Ready(frame) => Ok(Ok(frame)),
771                TryWait::NotReady(p) => Ok(Err(Self::Wgpu(p))),
772            },
773        }
774    }
775}
776
777/// How many readbacks the high-level [`Denoiser`] keeps in flight at
778/// once.
779///
780/// This has to match the backend's output-handle count, which is two.
781/// Going past it would reuse the oldest pending frame's output handle
782/// and quietly corrupt the results.
783pub const MAX_PENDING: usize = 2;
784
785/// How many source frames a windowed operation needs behind and ahead
786/// of its target frame, target frame itself not counted in either
787/// number.
788///
789/// `reseed` needs exactly `behind + 1 + ahead` frames, oldest first,
790/// with the target frame sitting at index `behind`. This is what tells
791/// a caller like `reseed` how wide a window to build, and it varies by
792/// algorithm because nl4d's own cross-frame accumulator needs more
793/// forward context than the NLM algorithms do. See
794/// [`Denoiser::window_span`].
795#[derive(Debug, Clone, Copy, PartialEq, Eq)]
796pub struct WindowSpan {
797    /// How many frames older than the target the window must include.
798    pub behind: usize,
799    /// How many frames newer than the target the window must include.
800    pub ahead: usize,
801}
802
803impl WindowSpan {
804    /// The full window size this span describes, target frame
805    /// included: `behind + 1 + ahead`.
806    pub fn frame_count(&self) -> usize {
807        self.behind + 1 + self.ahead
808    }
809}
810
811/// A stateful denoiser that cleans a stream of frames.
812///
813/// Push frames in order with [`push_frame`](Self::push_frame) and
814/// collect the cleaned ones with [`recv_frame`](Self::recv_frame) or
815/// [`try_recv_frame`](Self::try_recv_frame).
816///
817/// At the end of the stream call [`flush`](Self::flush) to drain
818/// whatever temporal context is left.
819///
820/// Input frames are `f32` values in `[0, 1]`, laid out as
821/// `width * height * channels`. Output comes back as a [`FrameOutput`]
822/// in whichever [`OutputFormat`] the options named.
823///
824/// ```no_run
825/// use av_denoise_core::accelerate::Accelerator;
826/// use av_denoise_core::{ChannelMode, Denoiser, DenoiserOptions, DenoisingMode, Device};
827///
828/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
829/// let options = DenoiserOptions::builder()
830///     .channel_mode(ChannelMode::Luma)
831///     .mode(DenoisingMode::Temporal { radius: 2 })
832///     .build();
833///
834/// let mut denoiser = Denoiser::create(
835///     &[Accelerator::Vulkan],
836///     &Device::Default,
837///     1920,
838///     1080,
839///     options,
840/// )?;
841///
842/// let frames: Vec<Vec<f32>> = read_my_frames();
843/// let mut cleaned: Vec<Vec<f32>> = Vec::new();
844///
845/// for frame in &frames {
846///     denoiser.push_frame(frame)?;
847///
848///     // Temporal denoising runs a few frames behind the input, so
849///     // there is not always one ready to collect.
850///     if let Some(out) = denoiser.recv_frame()? {
851///         cleaned.push(out.into_f32().expect("built for f32 output"));
852///     }
853/// }
854///
855/// // Drain the frames still inside the temporal window.
856/// denoiser.flush(|out| cleaned.push(out.into_f32().expect("built for f32 output")))?;
857/// # Ok(())
858/// # }
859/// # fn read_my_frames() -> Vec<Vec<f32>> { Vec::new() }
860/// ```
861pub struct Denoiser {
862    backend: Backend,
863    pending: VecDeque<BackendPending>,
864    accelerator: Accelerator,
865    width: u32,
866    height: u32,
867    temporal_radius: u32,
868    output_format: OutputFormat,
869    frames_pushed: u32,
870    /// Set once any call other than a `QueueFull` push has failed, so how
871    /// many frames are in flight is no longer known.
872    ///
873    /// Every entry point refuses to run while this is set.
874    /// [`Self::reset_stream`] clears it.
875    poisoned: bool,
876}
877
878impl Denoiser {
879    /// Tries each accelerator in `accelerators` in order and builds a
880    /// denoiser on the first one that works.
881    ///
882    /// `device` picks a non-default device on the chosen runtime.
883    ///
884    /// # Thread stack size
885    ///
886    /// cubecl spawns its own per-device worker thread, named
887    /// `DS{U,D}-…`, and runs GPU kernel codegen on it. That thread gets
888    /// Rust's default stack, which is `RUST_MIN_STACK` or 2 MiB when
889    /// that is unset.
890    ///
891    /// The windowed NLM kernels unroll their body
892    /// `(2 * search_radius + 1)^2` times, so a `search_radius` of about
893    /// 5 or more can overflow the 2 MiB default and abort the process.
894    ///
895    /// Callers using a `search_radius` above 4 should call
896    /// [`crate::raise_codegen_stack_limit`] before any cubecl thread
897    /// spawns, usually right at the top of `main`.
898    pub fn create(
899        accelerators: &[Accelerator],
900        device: &Device,
901        width: u32,
902        height: u32,
903        options: DenoiserOptions,
904    ) -> Result<Self, DenoiserError> {
905        let accelerator =
906            sniff_best_accelerator(accelerators, device).ok_or(DenoiserError::NoAcceleratorAvailable)?;
907
908        let params = options.to_nlm_params();
909        params.validate()?;
910        validate_dimensions(width, height)?;
911
912        let temporal_radius = params.temporal_radius;
913        let backend = build_backend(
914            accelerator,
915            device,
916            &options.algorithm,
917            params,
918            width,
919            height,
920            options.output_format,
921        )?;
922
923        Ok(Self {
924            backend,
925            pending: VecDeque::with_capacity(MAX_PENDING),
926            accelerator,
927            width,
928            height,
929            temporal_radius,
930            output_format: options.output_format,
931            frames_pushed: 0,
932            poisoned: false,
933        })
934    }
935
936    /// The accelerator [`sniff_best_accelerator`] picked.
937    pub fn selected_accelerator(&self) -> Accelerator {
938        self.accelerator
939    }
940
941    /// The width passed at construction.
942    pub fn width(&self) -> u32 {
943        self.width
944    }
945
946    /// The height passed at construction.
947    pub fn height(&self) -> u32 {
948        self.height
949    }
950
951    /// The temporal radius the resolved parameters run at.
952    pub fn temporal_radius(&self) -> u32 {
953        self.temporal_radius
954    }
955
956    /// The format every collected frame comes back in.
957    pub fn output_format(&self) -> OutputFormat {
958        self.output_format
959    }
960
961    /// How many frames behind and ahead of a target frame this
962    /// denoiser needs pushed, in order, to produce that frame's
963    /// output through [`PlanarDenoiser::reseed`](crate::PlanarDenoiser::reseed).
964    ///
965    /// Both NLM algorithms only ever need their own `2 * radius + 1`
966    /// sliding window, symmetric around the target frame:
967    /// `WindowSpan { behind: radius, ahead: radius }`.
968    ///
969    /// nl4d's cross-frame accumulator scatters every pass's
970    /// contribution across the `2 * radius + 1` frames the pass
971    /// reaches, and a frame's own region only starts collecting once
972    /// the pass that first reaches it, the one centred `radius` frames
973    /// behind it, has run. That earliest pass is itself only real once
974    /// the front end's own window is full at that centre, which needs
975    /// `radius` more frames behind it again. So nl4d needs the target's
976    /// own `radius`-wide neighbourhood doubled on both sides:
977    /// `WindowSpan { behind: 2 * radius, ahead: 2 * radius }`.
978    pub fn window_span(&self) -> WindowSpan {
979        let radius = self.temporal_radius as usize;
980        let span = if self.backend.is_nl4d() {
981            2 * radius
982        } else {
983            radius
984        };
985        WindowSpan {
986            behind: span,
987            ahead: span,
988        }
989    }
990
991    /// Uploads one frame into the temporal window.
992    ///
993    /// `frame` holds `width * height * channels` `f32` values in
994    /// `[0, 1]`.
995    ///
996    /// Once the window is full and the pipeline has room, this also
997    /// starts the kernels for the next denoised frame.
998    ///
999    /// Up to `MAX_PENDING` outputs can be in flight at once, so the GPU
1000    /// runs one frame's kernels while the previous frame's readback is
1001    /// still travelling. At that ceiling this returns
1002    /// [`DenoiserError::QueueFull`], and the caller has to drain a frame
1003    /// with [`Self::recv_frame`] before pushing more.
1004    ///
1005    /// Any other failure poisons the denoiser, so every further call
1006    /// returns [`DenoiserError::Poisoned`] until [`Self::reset_stream`]
1007    /// clears it. `QueueFull` does not poison, since it is the documented
1008    /// retry signal above.
1009    pub fn push_frame(&mut self, frame: &[f32]) -> Result<(), DenoiserError> {
1010        if self.poisoned {
1011            return Err(DenoiserError::Poisoned);
1012        }
1013        self.push_frame_inner(frame).inspect_err(|err| {
1014            if !matches!(err, DenoiserError::QueueFull) {
1015                self.poisoned = true;
1016            }
1017        })
1018    }
1019
1020    fn push_frame_inner(&mut self, frame: &[f32]) -> Result<(), DenoiserError> {
1021        // After `temporal_radius` real pushes the leading-edge mirror
1022        // has primed the window, so the next push produces a pending
1023        // frame. From then on every push takes a pending slot.
1024        let window_full = self.frames_pushed > self.temporal_radius;
1025        if window_full && self.pending.len() >= MAX_PENDING {
1026            return Err(DenoiserError::QueueFull);
1027        }
1028
1029        match &mut self.backend {
1030            #[cfg(feature = "cuda")]
1031            Backend::Cuda(d) => {
1032                d.push_frame(frame);
1033                if let Some(p) = d.denoise_submit()? {
1034                    self.pending.push_back(BackendPending::Cuda(p));
1035                }
1036            },
1037            #[cfg(feature = "rocm")]
1038            Backend::Rocm(d) => {
1039                d.push_frame(frame);
1040                if let Some(p) = d.denoise_submit()? {
1041                    self.pending.push_back(BackendPending::Rocm(p));
1042                }
1043            },
1044            #[cfg(any(feature = "vulkan", feature = "metal"))]
1045            Backend::Wgpu(d) => {
1046                d.push_frame(frame);
1047                if let Some(p) = d.denoise_submit()? {
1048                    self.pending.push_back(BackendPending::Wgpu(p));
1049                }
1050            },
1051        }
1052
1053        self.frames_pushed = self.frames_pushed.saturating_add(1);
1054        Ok(())
1055    }
1056
1057    /// Uploads one frame held as wire bytes into the temporal window.
1058    ///
1059    /// `planes` holds one `width * height` plane per channel at `depth`,
1060    /// which the GPU normalises and interleaves. The planes run Y, U, V
1061    /// for a fused frame and U, V for a chroma pair.
1062    ///
1063    /// Queueing, poisoning, and the `QueueFull` retry signal work exactly
1064    /// as they do for [`Self::push_frame`].
1065    pub fn push_frame_wire(&mut self, planes: &[&[u8]], depth: Depth) -> Result<(), DenoiserError> {
1066        if self.poisoned {
1067            return Err(DenoiserError::Poisoned);
1068        }
1069        self.push_frame_wire_inner(planes, depth).inspect_err(|err| {
1070            if !matches!(err, DenoiserError::QueueFull) {
1071                self.poisoned = true;
1072            }
1073        })
1074    }
1075
1076    fn push_frame_wire_inner(&mut self, planes: &[&[u8]], depth: Depth) -> Result<(), DenoiserError> {
1077        // The same window accounting `push_frame_inner` does.
1078        let window_full = self.frames_pushed > self.temporal_radius;
1079        if window_full && self.pending.len() >= MAX_PENDING {
1080            return Err(DenoiserError::QueueFull);
1081        }
1082
1083        match &mut self.backend {
1084            #[cfg(feature = "cuda")]
1085            Backend::Cuda(d) => {
1086                d.push_frame_wire(planes, depth);
1087                if let Some(p) = d.denoise_submit()? {
1088                    self.pending.push_back(BackendPending::Cuda(p));
1089                }
1090            },
1091            #[cfg(feature = "rocm")]
1092            Backend::Rocm(d) => {
1093                d.push_frame_wire(planes, depth);
1094                if let Some(p) = d.denoise_submit()? {
1095                    self.pending.push_back(BackendPending::Rocm(p));
1096                }
1097            },
1098            #[cfg(any(feature = "vulkan", feature = "metal"))]
1099            Backend::Wgpu(d) => {
1100                d.push_frame_wire(planes, depth);
1101                if let Some(p) = d.denoise_submit()? {
1102                    self.pending.push_back(BackendPending::Wgpu(p));
1103                }
1104            },
1105        }
1106
1107        self.frames_pushed = self.frames_pushed.saturating_add(1);
1108        Ok(())
1109    }
1110
1111    /// Uploads one frame held as wire bytes into the temporal window
1112    /// without starting a denoise.
1113    ///
1114    /// The wire counterpart of [`Self::push_frame_priming`].
1115    pub fn push_frame_wire_priming(&mut self, planes: &[&[u8]], depth: Depth) -> Result<(), DenoiserError> {
1116        if self.poisoned {
1117            return Err(DenoiserError::Poisoned);
1118        }
1119        match &mut self.backend {
1120            #[cfg(feature = "cuda")]
1121            Backend::Cuda(d) => d.push_frame_wire(planes, depth),
1122            #[cfg(feature = "rocm")]
1123            Backend::Rocm(d) => d.push_frame_wire(planes, depth),
1124            #[cfg(any(feature = "vulkan", feature = "metal"))]
1125            Backend::Wgpu(d) => d.push_frame_wire(planes, depth),
1126        }
1127
1128        self.frames_pushed = self.frames_pushed.saturating_add(1);
1129        Ok(())
1130    }
1131
1132    /// Uploads one frame into the temporal window without starting a
1133    /// denoise.
1134    ///
1135    /// The ring advances exactly as it does for [`Self::push_frame`], so
1136    /// the window still fills, but no kernels are submitted and no
1137    /// output is queued. This is how a caller that can hand over a whole
1138    /// window at once, rather than a strictly ordered stream, fills the
1139    /// window in one go and lets only the last push in it submit.
1140    ///
1141    /// A failure elsewhere poisons the denoiser, so this refuses to run
1142    /// until [`Self::reset_stream`] clears it.
1143    pub fn push_frame_priming(&mut self, frame: &[f32]) -> Result<(), DenoiserError> {
1144        if self.poisoned {
1145            return Err(DenoiserError::Poisoned);
1146        }
1147        match &mut self.backend {
1148            #[cfg(feature = "cuda")]
1149            Backend::Cuda(d) => d.push_frame(frame),
1150            #[cfg(feature = "rocm")]
1151            Backend::Rocm(d) => d.push_frame(frame),
1152            #[cfg(any(feature = "vulkan", feature = "metal"))]
1153            Backend::Wgpu(d) => d.push_frame(frame),
1154        }
1155
1156        self.frames_pushed = self.frames_pushed.saturating_add(1);
1157        Ok(())
1158    }
1159
1160    /// Drops the current stream and returns to the state a fresh
1161    /// denoiser starts in, keeping every GPU allocation.
1162    ///
1163    /// Frames still in flight are dropped without being read. A frame
1164    /// that [`Self::try_recv_frame`] has already polled is the one
1165    /// exception. Its readback is settled first, which blocks for the
1166    /// rest of that readback's latency and fails the way a
1167    /// [`Self::recv_frame`] on it would. At most one frame is ever in
1168    /// that state.
1169    ///
1170    /// This also clears the poison an earlier failure left, so it is the
1171    /// recovery path for [`DenoiserError::Poisoned`].
1172    pub fn reset_stream(&mut self) {
1173        self.pending.clear();
1174        self.frames_pushed = 0;
1175        self.poisoned = false;
1176
1177        match &mut self.backend {
1178            #[cfg(feature = "cuda")]
1179            Backend::Cuda(d) => d.reset_stream(),
1180            #[cfg(feature = "rocm")]
1181            Backend::Rocm(d) => d.reset_stream(),
1182            #[cfg(any(feature = "vulkan", feature = "metal"))]
1183            Backend::Wgpu(d) => d.reset_stream(),
1184        }
1185    }
1186
1187    /// Blocks until the in-flight denoise finishes and returns the
1188    /// cleaned frame.
1189    ///
1190    /// Returns `Ok(None)` when nothing is in flight, which happens while
1191    /// the temporal window is still filling up.
1192    ///
1193    /// A failure poisons the denoiser, so every further call returns
1194    /// [`DenoiserError::Poisoned`] until [`Self::reset_stream`] clears it.
1195    pub fn recv_frame(&mut self) -> Result<Option<FrameOutput>, DenoiserError> {
1196        if self.poisoned {
1197            return Err(DenoiserError::Poisoned);
1198        }
1199        self.recv_frame_inner().inspect_err(|_| self.poisoned = true)
1200    }
1201
1202    fn recv_frame_inner(&mut self) -> Result<Option<FrameOutput>, DenoiserError> {
1203        let Some(pending) = self.pending.pop_front() else {
1204            return Ok(None);
1205        };
1206        Ok(Some(pending.wait()?))
1207    }
1208
1209    /// Polls the in-flight denoise once.
1210    ///
1211    /// Returns `Ok(None)` both when nothing is in flight and when the in-flight readback
1212    /// has not landed yet, so `None` alone does not tell those two cases apart.
1213    ///
1214    /// A caller that needs the frame rather than just checking on it should
1215    /// use [`Self::recv_frame`] instead.
1216    ///
1217    /// This only avoids blocking on the wgpu backends, meaning Vulkan and Metal.
1218    /// On CUDA and ROCm the readback completes synchronously on its first poll,
1219    /// so this call blocks until the readback lands there, the same as `recv_frame`.
1220    ///
1221    /// A poll that returns `None` for an in-flight frame commits the
1222    /// denoiser to finishing that readback. Dropping the denoiser or
1223    /// calling [`Self::reset_stream`] before it lands blocks until it
1224    /// does. On the wgpu backends the first poll maps a staging buffer
1225    /// that only the finished readback can unmap, and abandoning it
1226    /// would break every later call on the device.
1227    ///
1228    /// A failure poisons the denoiser, so every further call returns
1229    /// [`DenoiserError::Poisoned`] until [`Self::reset_stream`] clears it.
1230    pub fn try_recv_frame(&mut self) -> Result<Option<FrameOutput>, DenoiserError> {
1231        if self.poisoned {
1232            return Err(DenoiserError::Poisoned);
1233        }
1234        self.try_recv_frame_inner().inspect_err(|_| self.poisoned = true)
1235    }
1236
1237    fn try_recv_frame_inner(&mut self) -> Result<Option<FrameOutput>, DenoiserError> {
1238        let Some(pending) = self.pending.pop_front() else {
1239            return Ok(None);
1240        };
1241
1242        match pending.try_wait()? {
1243            Ok(frame) => Ok(Some(frame)),
1244            Err(pending) => {
1245                self.pending.push_front(pending);
1246                Ok(None)
1247            },
1248        }
1249    }
1250
1251    /// Drains the in-flight frames and the trailing temporal tail,
1252    /// handing each frame it produces to `sink`.
1253    ///
1254    /// The tail is padded by repeating the last pushed frame.
1255    ///
1256    /// On success the denoiser is ready for a fresh, unrelated stream of
1257    /// the same size and parameters. Pushing again after a flush starts
1258    /// a new temporal window from scratch, and flushing more than once
1259    /// is fine.
1260    ///
1261    /// A failure poisons the denoiser, so every further call returns
1262    /// [`DenoiserError::Poisoned`] until [`Self::reset_stream`] clears it.
1263    pub fn flush(&mut self, sink: impl FnMut(FrameOutput)) -> Result<(), DenoiserError> {
1264        if self.poisoned {
1265            return Err(DenoiserError::Poisoned);
1266        }
1267        self.flush_inner(sink).inspect_err(|_| self.poisoned = true)
1268    }
1269
1270    fn flush_inner(&mut self, mut sink: impl FnMut(FrameOutput)) -> Result<(), DenoiserError> {
1271        // Drain the whole pending pipeline, up to MAX_PENDING frames,
1272        // before submitting the trailing-tail mirrors. This also leaves
1273        // every output slot free, so the tail's own readbacks cannot be
1274        // handed a slot a streaming readback is still reading.
1275        while let Some(frame) = self.recv_frame_inner()? {
1276            sink(frame);
1277        }
1278
1279        // The tail frames come back through each algorithm's own
1280        // blocking readback, in the same format as every streaming
1281        // frame, so they are quantised by the same pack kernel.
1282        match &mut self.backend {
1283            #[cfg(feature = "cuda")]
1284            Backend::Cuda(d) => d.flush(|frame| sink(frame.clone()))?,
1285            #[cfg(feature = "rocm")]
1286            Backend::Rocm(d) => d.flush(|frame| sink(frame.clone()))?,
1287            #[cfg(any(feature = "vulkan", feature = "metal"))]
1288            Backend::Wgpu(d) => d.flush(|frame| sink(frame.clone()))?,
1289        }
1290
1291        // The backend has already reset its own stream indices. Reset
1292        // the outer push counter too, so the next push re-arms the
1293        // window-priming check at the top of `push_frame`.
1294        self.frames_pushed = 0;
1295
1296        Ok(())
1297    }
1298
1299    /// Sets the poison flag directly, without going through a failing
1300    /// call, so a test can check what a caller sees once the flag is
1301    /// already set and what recovers it.
1302    #[cfg(test)]
1303    pub(crate) fn poison_for_test(&mut self) {
1304        self.poisoned = true;
1305    }
1306
1307    /// The backend's packed-word output buffers, which only exist in
1308    /// wire mode.
1309    #[cfg(test)]
1310    pub(crate) fn wire_outputs_for_test(&self) -> Option<&[cubecl::server::Handle; 2]> {
1311        self.backend.wire_outputs()
1312    }
1313}
1314
1315fn build_backend(
1316    accel: Accelerator,
1317    device: &Device,
1318    algorithm: &Algorithm,
1319    params: NlmParams,
1320    width: u32,
1321    height: u32,
1322    output_format: OutputFormat,
1323) -> Result<Backend, DenoiserError> {
1324    match accel {
1325        #[cfg(feature = "cuda")]
1326        Accelerator::Cuda => {
1327            let dev = device.to_cuda()?;
1328            let client = <cubecl::cuda::CudaRuntime as Runtime>::client(&dev);
1329            Ok(Backend::Cuda(build_engine(
1330                &client,
1331                algorithm,
1332                params,
1333                width,
1334                height,
1335                output_format,
1336            )?))
1337        },
1338        #[cfg(feature = "rocm")]
1339        Accelerator::Rocm => {
1340            let dev = device.to_amd()?;
1341            let client = <cubecl::hip::HipRuntime as Runtime>::client(&dev);
1342            Ok(Backend::Rocm(build_engine(
1343                &client,
1344                algorithm,
1345                params,
1346                width,
1347                height,
1348                output_format,
1349            )?))
1350        },
1351        #[cfg(feature = "vulkan")]
1352        Accelerator::Vulkan => {
1353            let dev = device.to_wgpu()?;
1354            let client = <cubecl::wgpu::WgpuRuntime as Runtime>::client(&dev);
1355            Ok(Backend::Wgpu(build_engine(
1356                &client,
1357                algorithm,
1358                params,
1359                width,
1360                height,
1361                output_format,
1362            )?))
1363        },
1364        #[cfg(feature = "metal")]
1365        Accelerator::Metal => {
1366            let dev = device.to_wgpu()?;
1367            let client = <cubecl::wgpu::WgpuRuntime as Runtime>::client(&dev);
1368            Ok(Backend::Wgpu(build_engine(
1369                &client,
1370                algorithm,
1371                params,
1372                width,
1373                height,
1374                output_format,
1375            )?))
1376        },
1377        // Keeps the match exhaustive on docs.rs, where `cfg(docsrs)`
1378        // widens the `Accelerator` enum to include variants whose
1379        // backend feature is not enabled. Never reached at runtime.
1380        #[cfg(docsrs)]
1381        #[expect(
1382            unreachable_patterns,
1383            reason = "the arm only keeps the match exhaustive on docs.rs"
1384        )]
1385        _ => unreachable!(),
1386    }
1387}
1388
1389#[cfg(test)]
1390mod options_tests {
1391    use super::*;
1392
1393    /// `Algorithm::NlmeansHq` with `hq` overridden and everything else
1394    /// left at its default.
1395    fn hq(hq: HqParams) -> Algorithm {
1396        Algorithm::NlmeansHq(NlmeansHqOptions {
1397            hq,
1398            ..NlmeansHqOptions::default()
1399        })
1400    }
1401
1402    /// `Algorithm::Nlmeans` with `tuning` overridden.
1403    fn fast_tuned(tuning: NlmTuning) -> Algorithm {
1404        Algorithm::Nlmeans(NlmeansOptions {
1405            tuning,
1406            ..NlmeansOptions::default()
1407        })
1408    }
1409
1410    #[test]
1411    fn nl4d_default_lambda_ht_differs_between_luma_and_chroma() {
1412        let luma = nl4d_default_lambda_ht(ChannelMode::Luma);
1413        let chroma = nl4d_default_lambda_ht(ChannelMode::Chroma);
1414
1415        assert!((luma - 5.2).abs() < f32::EPSILON);
1416        assert!((chroma - 3.4).abs() < f32::EPSILON);
1417        assert!(
1418            (chroma - luma).abs() > f32::EPSILON,
1419            "the two planes should not resolve to the same default"
1420        );
1421    }
1422
1423    #[test]
1424    fn nl4d_default_lambda_ht_yuv_reads_the_luma_value() {
1425        let yuv = nl4d_default_lambda_ht(ChannelMode::Yuv);
1426        let luma = nl4d_default_lambda_ht(ChannelMode::Luma);
1427
1428        assert!((yuv - luma).abs() < f32::EPSILON);
1429    }
1430
1431    #[test]
1432    fn resolve_lambda_ht_unset_uses_the_per_plane_default() {
1433        let opts = Nl4dOptions::default();
1434
1435        let luma = resolve_lambda_ht(&opts, ChannelMode::Luma).expect("the default scale is in range");
1436        let chroma = resolve_lambda_ht(&opts, ChannelMode::Chroma).expect("the default scale is in range");
1437
1438        assert!((luma - 5.2).abs() < f32::EPSILON, "got {luma}");
1439        assert!((chroma - 3.4).abs() < f32::EPSILON, "got {chroma}");
1440    }
1441
1442    #[test]
1443    fn resolve_lambda_ht_explicit_value_overrides_every_plane() {
1444        let opts = Nl4dOptions {
1445            lambda_ht: Some(4.4),
1446            ..Nl4dOptions::default()
1447        };
1448
1449        for channels in [ChannelMode::Luma, ChannelMode::Chroma, ChannelMode::Yuv] {
1450            let got = resolve_lambda_ht(&opts, channels).expect("the default scale is in range");
1451            assert!(
1452                (got - 4.4).abs() < f32::EPSILON,
1453                "channels {channels:?} got {got}"
1454            );
1455        }
1456    }
1457
1458    #[test]
1459    fn resolve_lambda_ht_default_scale_leaves_the_value_alone() {
1460        let opts = Nl4dOptions::default();
1461
1462        for channels in [ChannelMode::Luma, ChannelMode::Chroma, ChannelMode::Yuv] {
1463            let got = resolve_lambda_ht(&opts, channels).expect("the default scale is in range");
1464            let want = nl4d_default_lambda_ht(channels);
1465            assert!(
1466                (got - want).abs() < f32::EPSILON,
1467                "channels {channels:?} got {got}"
1468            );
1469        }
1470    }
1471
1472    #[test]
1473    fn resolve_lambda_ht_scale_multiplies_the_per_plane_default() {
1474        let opts = Nl4dOptions {
1475            lambda_ht_scale: 1.1,
1476            ..Nl4dOptions::default()
1477        };
1478
1479        for channels in [ChannelMode::Luma, ChannelMode::Chroma, ChannelMode::Yuv] {
1480            let got = resolve_lambda_ht(&opts, channels).expect("1.1 is in range");
1481            let want = nl4d_default_lambda_ht(channels) * 1.1;
1482            assert!(
1483                (got - want).abs() < 1e-5,
1484                "channels {channels:?} got {got}, want {want}"
1485            );
1486        }
1487    }
1488
1489    /// The scale is not limited to the defaults. Pinning one plane and
1490    /// scaling both is the combination this exists for.
1491    #[test]
1492    fn resolve_lambda_ht_scale_multiplies_an_explicit_value() {
1493        let opts = Nl4dOptions {
1494            lambda_ht: Some(5.0),
1495            lambda_ht_scale: 0.9,
1496            ..Nl4dOptions::default()
1497        };
1498
1499        let got = resolve_lambda_ht(&opts, ChannelMode::Luma).expect("0.9 is in range");
1500        assert!((got - 4.5).abs() < 1e-5, "got {got}");
1501    }
1502
1503    #[test]
1504    fn resolve_lambda_ht_rejects_an_out_of_range_scale() {
1505        for bad in [0.0, -1.0, 0.05, 10.5, f32::NAN, f32::INFINITY] {
1506            let opts = Nl4dOptions {
1507                lambda_ht_scale: bad,
1508                ..Nl4dOptions::default()
1509            };
1510            let err = resolve_lambda_ht(&opts, ChannelMode::Luma).unwrap_err();
1511            assert!(
1512                err.contains("lambda_ht_scale"),
1513                "lambda_ht_scale={bad} should be rejected, got {err}"
1514            );
1515        }
1516    }
1517
1518    #[test]
1519    fn the_default_algorithm_is_the_fast_nlmeans_path() {
1520        let opts = DenoiserOptions::builder().build();
1521        assert_eq!(opts.algorithm, Algorithm::Nlmeans(NlmeansOptions::default()));
1522    }
1523
1524    #[test]
1525    fn spatial_mode_maps_to_zero_temporal_radius() {
1526        let opts = DenoiserOptions::builder()
1527            .channel_mode(ChannelMode::Yuv)
1528            .mode(DenoisingMode::Spacial)
1529            .build();
1530        let params = opts.to_nlm_params();
1531
1532        assert_eq!(params.temporal_radius, 0);
1533        assert_eq!(params.channels, ChannelMode::Yuv);
1534    }
1535
1536    #[test]
1537    fn temporal_mode_propagates_radius() {
1538        let opts = DenoiserOptions::builder()
1539            .mode(DenoisingMode::Temporal { radius: 3 })
1540            .build();
1541        let params = opts.to_nlm_params();
1542
1543        assert_eq!(params.temporal_radius, 3);
1544    }
1545
1546    #[test]
1547    fn prefilter_passthrough() {
1548        let opts = DenoiserOptions::builder()
1549            .algorithm(Algorithm::Nlmeans(NlmeansOptions {
1550                prefilter: PrefilterMode::Bilateral {
1551                    sigma_s: 3.0,
1552                    sigma_r: 0.02,
1553                },
1554                ..NlmeansOptions::default()
1555            }))
1556            .build();
1557        let params = opts.to_nlm_params();
1558
1559        assert!(matches!(params.prefilter, PrefilterMode::Bilateral { .. }));
1560    }
1561
1562    #[test]
1563    fn hq_unset_prefilter_defaults_to_none() {
1564        let opts = DenoiserOptions::builder()
1565            .algorithm(hq(HqParams::default()))
1566            .build();
1567        let params = opts.to_nlm_params();
1568
1569        assert!(matches!(params.prefilter, PrefilterMode::None));
1570    }
1571
1572    #[test]
1573    fn fast_unset_prefilter_defaults_to_none() {
1574        let opts = DenoiserOptions::builder()
1575            .algorithm(Algorithm::Nlmeans(NlmeansOptions::default()))
1576            .build();
1577        let params = opts.to_nlm_params();
1578
1579        assert!(matches!(params.prefilter, PrefilterMode::None));
1580    }
1581
1582    #[test]
1583    fn hq_unset_strength_defaults_to_hq_default_strength() {
1584        // Default channel_mode is Yuv, default mode is Spacial (radius 0).
1585        let opts = DenoiserOptions::builder()
1586            .algorithm(hq(HqParams::default()))
1587            .build();
1588        let params = opts.to_nlm_params();
1589
1590        let expected = hq_default_strength(ChannelMode::Yuv, 0);
1591        assert!((params.strength - expected).abs() < f32::EPSILON);
1592    }
1593
1594    #[test]
1595    fn hq_no_auto_strength_falls_back_to_the_legacy_absolute_default() {
1596        // `effective_strength_with` only reads `strength` as a
1597        // multiplier on the measured sigma when `auto_strength` is true.
1598        // With it false, `strength` is an FFmpeg-style absolute value,
1599        // so the fallback has to be the fast path's absolute default
1600        // rather than a calibrated multiplier from
1601        // `hq_default_strength`.
1602        let opts = DenoiserOptions::builder()
1603            .algorithm(hq(HqParams {
1604                auto_strength: false,
1605                ..HqParams::default()
1606            }))
1607            .build();
1608        let params = opts.to_nlm_params();
1609
1610        let expected = NlmParams::default().strength;
1611        assert!(
1612            (params.strength - expected).abs() < f32::EPSILON,
1613            "expected the legacy absolute default {expected}, got {}, which looks like the \
1614             auto-strength multiplier table leaking through",
1615            params.strength
1616        );
1617    }
1618
1619    #[test]
1620    fn hq_luma_r4_uses_measured_table_value() {
1621        let opts = DenoiserOptions::builder()
1622            .channel_mode(ChannelMode::Luma)
1623            .mode(DenoisingMode::Temporal { radius: 4 })
1624            .algorithm(hq(HqParams::default()))
1625            .build();
1626        let params = opts.to_nlm_params();
1627
1628        assert!((params.strength - 0.35).abs() < f32::EPSILON);
1629    }
1630
1631    #[test]
1632    fn hq_chroma_r4_uses_measured_table_value() {
1633        let opts = DenoiserOptions::builder()
1634            .channel_mode(ChannelMode::Chroma)
1635            .mode(DenoisingMode::Temporal { radius: 4 })
1636            .algorithm(hq(HqParams::default()))
1637            .build();
1638        let params = opts.to_nlm_params();
1639
1640        assert!((params.strength - 0.70).abs() < f32::EPSILON);
1641    }
1642
1643    #[test]
1644    fn hq_yuv_r8_uses_measured_table_value() {
1645        let opts = DenoiserOptions::builder()
1646            .channel_mode(ChannelMode::Yuv)
1647            .mode(DenoisingMode::Temporal { radius: 8 })
1648            .algorithm(hq(HqParams::default()))
1649            .build();
1650        let params = opts.to_nlm_params();
1651
1652        assert!((params.strength - 0.30).abs() < f32::EPSILON);
1653    }
1654
1655    #[test]
1656    fn hq_spacial_mode_uses_radius_zero_table_values() {
1657        for channels in [ChannelMode::Luma, ChannelMode::Chroma, ChannelMode::Yuv] {
1658            let opts = DenoiserOptions::builder()
1659                .channel_mode(channels)
1660                .mode(DenoisingMode::Spacial)
1661                .algorithm(hq(HqParams::default()))
1662                .build();
1663            let params = opts.to_nlm_params();
1664
1665            let expected = hq_default_strength(channels, 0);
1666            assert!(
1667                (params.strength - expected).abs() < f32::EPSILON,
1668                "for channels {channels:?} expected {expected}, got {}",
1669                params.strength
1670            );
1671        }
1672    }
1673
1674    #[test]
1675    fn hq_explicit_strength_wins_over_the_table_for_every_plane() {
1676        for channels in [ChannelMode::Luma, ChannelMode::Chroma, ChannelMode::Yuv] {
1677            let opts = DenoiserOptions::builder()
1678                .channel_mode(channels)
1679                .mode(DenoisingMode::Temporal { radius: 4 })
1680                .algorithm(Algorithm::NlmeansHq(NlmeansHqOptions {
1681                    nlm: NlmeansOptions {
1682                        tuning: NlmTuning {
1683                            strength: Some(0.99),
1684                            ..NlmTuning::default()
1685                        },
1686                        ..NlmeansOptions::default()
1687                    },
1688                    hq: HqParams::default(),
1689                }))
1690                .build();
1691            let params = opts.to_nlm_params();
1692
1693            assert!(
1694                (params.strength - 0.99).abs() < f32::EPSILON,
1695                "for channels {channels:?} the explicit strength was overridden by the table"
1696            );
1697        }
1698    }
1699
1700    #[test]
1701    fn fast_unset_strength_defaults_to_legacy_default() {
1702        let opts = DenoiserOptions::builder()
1703            .algorithm(Algorithm::Nlmeans(NlmeansOptions::default()))
1704            .build();
1705        let params = opts.to_nlm_params();
1706
1707        assert!((params.strength - 1.2).abs() < f32::EPSILON);
1708    }
1709
1710    #[test]
1711    fn nl4d_options_default_matches_nl4d_params_default() {
1712        let opts = Nl4dOptions::default();
1713        let params = crate::nl4d::Nl4dParams::default();
1714
1715        assert_eq!(opts.refine, params.refine);
1716        assert_eq!(opts.spatial_radius, params.spatial_radius);
1717        assert!((opts.c_min - params.c_min).abs() < f32::EPSILON);
1718        assert_eq!(opts.confidence_variance, params.confidence_variance);
1719        // The two `lambda_ht` fields hold different things, so they are
1720        // not compared. `opts.lambda_ht` stays `None` and is deferred to
1721        // `nl4d_default_lambda_ht` once the plane is known (see
1722        // `resolve_lambda_ht_unset_uses_the_per_plane_default` above),
1723        // while `params.lambda_ht` is a concrete default mirroring the
1724        // Luma/Yuv value.
1725        assert_eq!(opts.lambda_ht, None);
1726        assert!((params.lambda_ht - nl4d_default_lambda_ht(ChannelMode::Yuv)).abs() < f32::EPSILON);
1727    }
1728
1729    /// nl4d takes its own noise and confidence knobs rather than a whole
1730    /// [`HqParams`], so the three it does take have to reach the front
1731    /// end and the rest have to arrive at their defaults.
1732    #[test]
1733    fn nl4d_builds_the_front_ends_hq_params_from_its_own_fields() {
1734        let opts = DenoiserOptions::builder()
1735            .mode(DenoisingMode::Temporal { radius: 2 })
1736            .algorithm(Algorithm::Nl4d(Nl4dOptions {
1737                sigma: Some(0.02),
1738                sigma_scale: 1.3,
1739                thsad_scale: 0.8,
1740                ..Nl4dOptions::default()
1741            }))
1742            .build();
1743        let params = opts.to_nlm_params();
1744
1745        let hq = params.hq.expect("nl4d always runs the hq front end");
1746        assert_eq!(hq.sigma_override, Some(0.02));
1747        assert!((hq.sigma_scale - 1.3).abs() < f32::EPSILON);
1748        assert!((hq.thsad_scale - 0.8).abs() < f32::EPSILON);
1749        assert!(
1750            hq.temporal_confidence,
1751            "the grouping kernel reads the confidence scores, so this cannot be off"
1752        );
1753    }
1754
1755    /// The temporal radius has one source now, `mode`, so nl4d cannot
1756    /// disagree with the front end's ring about how wide the window is.
1757    #[test]
1758    fn nl4d_reads_its_temporal_radius_from_the_denoising_mode() {
1759        for radius in [1u32, 4, 8] {
1760            let opts = DenoiserOptions::builder()
1761                .mode(DenoisingMode::Temporal { radius })
1762                .algorithm(Algorithm::Nl4d(Nl4dOptions::default()))
1763                .build();
1764
1765            assert_eq!(opts.to_nlm_params().temporal_radius, radius);
1766        }
1767    }
1768
1769    /// nl4d never runs an NLM weighting pass, so a prefilter would cost
1770    /// a GPU pass per frame producing a reference image nothing reads.
1771    #[test]
1772    fn nl4d_never_builds_a_prefilter() {
1773        let opts = DenoiserOptions::builder()
1774            .mode(DenoisingMode::Temporal { radius: 2 })
1775            .algorithm(Algorithm::Nl4d(Nl4dOptions::default()))
1776            .build();
1777
1778        assert!(matches!(opts.to_nlm_params().prefilter, PrefilterMode::None));
1779    }
1780
1781    /// Nothing in the nl4d path reads `strength`, so it stays at the
1782    /// library default rather than picking up HQ's calibrated table.
1783    #[test]
1784    fn nl4d_leaves_the_nlm_weighting_knobs_at_their_defaults() {
1785        let defaults = NlmParams::default();
1786        let opts = DenoiserOptions::builder()
1787            .channel_mode(ChannelMode::Luma)
1788            .mode(DenoisingMode::Temporal { radius: 4 })
1789            .algorithm(Algorithm::Nl4d(Nl4dOptions::default()))
1790            .build();
1791        let params = opts.to_nlm_params();
1792
1793        assert!((params.strength - defaults.strength).abs() < f32::EPSILON);
1794        assert_eq!(params.search_radius, defaults.search_radius);
1795        assert_eq!(params.patch_radius, defaults.patch_radius);
1796        assert!((params.self_weight - defaults.self_weight).abs() < f32::EPSILON);
1797    }
1798
1799    /// nl4d always tracks motion, so its `MotionSearch` reaches the
1800    /// front end as an active `Mvtools` mode.
1801    #[test]
1802    fn nl4d_motion_search_becomes_an_active_mvtools_mode() {
1803        let opts = DenoiserOptions::builder()
1804            .mode(DenoisingMode::Temporal { radius: 2 })
1805            .algorithm(Algorithm::Nl4d(Nl4dOptions {
1806                motion: MotionSearch {
1807                    blksize: 32,
1808                    overlap: 16,
1809                    search_radius: 6,
1810                    pyramid_levels: 1,
1811                    estimation: MotionEstimation::Direct,
1812                },
1813                ..Nl4dOptions::default()
1814            }))
1815            .build();
1816        let params = opts.to_nlm_params();
1817
1818        assert!(matches!(
1819            params.motion_compensation,
1820            MotionCompensationMode::Mvtools {
1821                blksize: 32,
1822                overlap: 16,
1823                search_radius: 6,
1824                pyramid_levels: 1,
1825                estimation: MotionEstimation::Direct,
1826            }
1827        ));
1828    }
1829
1830    #[test]
1831    fn nl4d_motion_search_defaults_match_the_front_ends_own_defaults() {
1832        let opts = DenoiserOptions::builder()
1833            .mode(DenoisingMode::Temporal { radius: 2 })
1834            .algorithm(Algorithm::Nl4d(Nl4dOptions::default()))
1835            .build();
1836        let params = opts.to_nlm_params();
1837
1838        assert_eq!(
1839            params.motion_compensation,
1840            crate::nl4d::Nl4dParams::default().nlm.motion_compensation
1841        );
1842    }
1843
1844    #[test]
1845    fn motion_compensation_passthrough() {
1846        let opts = DenoiserOptions::builder()
1847            .mode(DenoisingMode::Temporal { radius: 1 })
1848            .algorithm(Algorithm::Nlmeans(NlmeansOptions {
1849                motion_compensation: MotionCompensationMode::Mvtools {
1850                    blksize: 16,
1851                    overlap: 8,
1852                    search_radius: 4,
1853                    pyramid_levels: 2,
1854                    estimation: MotionEstimation::Direct,
1855                },
1856                ..NlmeansOptions::default()
1857            }))
1858            .build();
1859        let params = opts.to_nlm_params();
1860
1861        assert!(matches!(
1862            params.motion_compensation,
1863            MotionCompensationMode::Mvtools {
1864                blksize: 16,
1865                overlap: 8,
1866                search_radius: 4,
1867                pyramid_levels: 2,
1868                ..
1869            }
1870        ));
1871    }
1872
1873    #[test]
1874    fn motion_compensation_defaults_to_none() {
1875        let opts = DenoiserOptions::builder().build();
1876        let params = opts.to_nlm_params();
1877        assert!(matches!(params.motion_compensation, MotionCompensationMode::None));
1878    }
1879
1880    #[test]
1881    fn nlm_tuning_overrides_individual_fields() {
1882        let defaults = NlmParams::default();
1883        let opts = DenoiserOptions::builder()
1884            .algorithm(fast_tuned(NlmTuning {
1885                search_radius: Some(7),
1886                patch_radius: None,
1887                strength: Some(2.5),
1888                self_weight: None,
1889            }))
1890            .build();
1891        let params = opts.to_nlm_params();
1892
1893        assert_eq!(params.search_radius, 7);
1894        assert_eq!(params.patch_radius, defaults.patch_radius);
1895        assert!((params.strength - 2.5).abs() < f32::EPSILON);
1896        assert!((params.self_weight - defaults.self_weight).abs() < f32::EPSILON);
1897    }
1898}
1899
1900#[cfg(all(test, feature = "vulkan"))]
1901mod tests {
1902    use super::*;
1903
1904    fn opts(mode: DenoisingMode) -> DenoiserOptions {
1905        DenoiserOptions::builder()
1906            .channel_mode(ChannelMode::Luma)
1907            .mode(mode)
1908            .build()
1909    }
1910
1911    fn frame(w: u32, h: u32) -> Vec<f32> {
1912        vec![0.5f32; (w * h) as usize]
1913    }
1914
1915    fn f32_out(out: FrameOutput) -> Vec<f32> {
1916        out.into_f32().expect("f32 output")
1917    }
1918
1919    #[test]
1920    fn spatial_denoise_roundtrip() {
1921        let mut d = Denoiser::create(
1922            &[Accelerator::Vulkan],
1923            &Device::Default,
1924            16,
1925            16,
1926            opts(DenoisingMode::Spacial),
1927        )
1928        .expect("denoiser construction failed");
1929        assert_eq!(d.selected_accelerator(), Accelerator::Vulkan);
1930
1931        d.push_frame(&frame(16, 16)).expect("push failed");
1932        let out = f32_out(d.recv_frame().expect("recv failed").expect("no frame"));
1933        assert_eq!(out.len(), 16 * 16);
1934    }
1935
1936    #[test]
1937    fn nl4d_algorithm_round_trips_through_the_facade() {
1938        let opts = DenoiserOptions::builder()
1939            .channel_mode(ChannelMode::Luma)
1940            .mode(DenoisingMode::Temporal { radius: 2 })
1941            .algorithm(Algorithm::Nl4d(Nl4dOptions::default()))
1942            .build();
1943        let mut d = Denoiser::create(&[Accelerator::Vulkan], &Device::Default, 16, 16, opts)
1944            .expect("nl4d denoiser construction failed");
1945        assert_eq!(d.selected_accelerator(), Accelerator::Vulkan);
1946
1947        // temporal_radius is 2, so a single push does not fill the
1948        // window yet, the same convention every temporal algorithm here
1949        // follows.
1950        d.push_frame(&frame(16, 16)).expect("push failed");
1951        assert!(d.recv_frame().expect("recv failed").is_none());
1952
1953        let mut out = Vec::new();
1954        d.flush(|f| out.push(f32_out(f))).expect("flush failed");
1955        assert_eq!(out.len(), 1, "expected exactly one output for one pushed frame");
1956        assert_eq!(out[0].len(), 16 * 16);
1957    }
1958
1959    /// nl4d groups patches across neighbouring frames, so a spatial
1960    /// mode leaves it nothing to do. The temporal radius has one source
1961    /// now, `mode`, so this is the only way to ask for that.
1962    #[test]
1963    fn nl4d_rejects_a_spatial_denoising_mode() {
1964        let opts = DenoiserOptions::builder()
1965            .channel_mode(ChannelMode::Luma)
1966            .mode(DenoisingMode::Spacial)
1967            .algorithm(Algorithm::Nl4d(Nl4dOptions::default()))
1968            .build();
1969        let result = Denoiser::create(&[Accelerator::Vulkan], &Device::Default, 16, 16, opts);
1970
1971        match result {
1972            Err(DenoiserError::Other(e)) => assert!(
1973                e.to_string().contains("temporal window"),
1974                "unexpected error message: {e}"
1975            ),
1976            Err(other) => panic!("expected DenoiserError::Other, got {other:?}"),
1977            Ok(_) => panic!("expected a rejection, got Ok"),
1978        }
1979    }
1980
1981    /// Both NLM algorithms only need a symmetric `2r+1` window, so
1982    /// `window_span` must report the same radius on both sides.
1983    #[test]
1984    fn window_span_is_symmetric_for_nlmeans() {
1985        let opts = DenoiserOptions::builder()
1986            .channel_mode(ChannelMode::Luma)
1987            .mode(DenoisingMode::Temporal { radius: 3 })
1988            .algorithm(Algorithm::Nlmeans(NlmeansOptions::default()))
1989            .build();
1990        let d = Denoiser::create(&[Accelerator::Vulkan], &Device::Default, 16, 16, opts)
1991            .expect("denoiser construction failed");
1992
1993        let span = d.window_span();
1994        assert_eq!(span.behind, 3, "behind should equal the temporal radius");
1995        assert_eq!(span.ahead, 3, "ahead should equal the temporal radius");
1996    }
1997
1998    /// nl4d's cross-frame accumulator needs the target's own `radius`
1999    /// neighbourhood doubled on both sides, so both `behind` and
2000    /// `ahead` must come out to `2 * radius`.
2001    #[test]
2002    fn window_span_is_doubled_on_both_sides_for_nl4d() {
2003        let opts = DenoiserOptions::builder()
2004            .channel_mode(ChannelMode::Luma)
2005            .mode(DenoisingMode::Temporal { radius: 3 })
2006            .algorithm(Algorithm::Nl4d(Nl4dOptions::default()))
2007            .build();
2008        let d = Denoiser::create(&[Accelerator::Vulkan], &Device::Default, 16, 16, opts)
2009            .expect("nl4d denoiser construction failed");
2010
2011        let span = d.window_span();
2012        assert_eq!(span.behind, 6, "behind should equal 2 * the temporal radius");
2013        assert_eq!(span.ahead, 6, "ahead should equal 2 * the temporal radius");
2014    }
2015
2016    #[test]
2017    fn invalid_params_surface_as_error() {
2018        let bad = DenoiserOptions::builder()
2019            .algorithm(Algorithm::Nlmeans(NlmeansOptions {
2020                tuning: NlmTuning {
2021                    strength: Some(0.0),
2022                    ..NlmTuning::default()
2023                },
2024                ..NlmeansOptions::default()
2025            }))
2026            .build();
2027        let result = Denoiser::create(&[Accelerator::Vulkan], &Device::Default, 16, 16, bad);
2028
2029        match result {
2030            Err(DenoiserError::Other(_)) => {},
2031            Err(other) => panic!("expected DenoiserError::Other, got {other:?}"),
2032            Ok(_) => panic!("expected validation error, got Ok"),
2033        }
2034    }
2035
2036    #[test]
2037    fn tiny_frame_dimensions_surface_as_error() {
2038        let result = Denoiser::create(
2039            &[Accelerator::Vulkan],
2040            &Device::Default,
2041            2,
2042            2,
2043            opts(DenoisingMode::Spacial),
2044        );
2045
2046        match result {
2047            Err(DenoiserError::Other(e)) => {
2048                assert!(
2049                    e.to_string().contains("supported minimum"),
2050                    "unexpected error message: {e}"
2051                );
2052            },
2053            Err(other) => panic!("expected DenoiserError::Other, got {other:?}"),
2054            Ok(_) => panic!("expected dimension validation error, got Ok"),
2055        }
2056    }
2057
2058    #[test]
2059    fn push_after_pending_returns_queue_full() {
2060        let mut d = Denoiser::create(
2061            &[Accelerator::Vulkan],
2062            &Device::Default,
2063            16,
2064            16,
2065            opts(DenoisingMode::Spacial),
2066        )
2067        .unwrap();
2068
2069        // The pipeline is two deep because the output handles are
2070        // double-buffered, so the first two pushes both submit. The
2071        // third would overwrite the oldest pending frame's output slot,
2072        // so it is rejected with QueueFull.
2073        d.push_frame(&frame(16, 16)).unwrap();
2074        d.push_frame(&frame(16, 16)).unwrap();
2075        let err = d.push_frame(&frame(16, 16)).expect_err("expected QueueFull");
2076        assert!(matches!(err, DenoiserError::QueueFull));
2077
2078        let out = f32_out(d.recv_frame().unwrap().unwrap());
2079        assert_eq!(out.len(), 16 * 16);
2080
2081        // After draining one slot the next push must succeed.
2082        d.push_frame(&frame(16, 16)).expect("push after drain failed");
2083    }
2084
2085    /// `QueueFull` is the documented retry signal, so it must not leave
2086    /// the denoiser poisoned.
2087    #[test]
2088    fn queue_full_does_not_poison() {
2089        let mut d = Denoiser::create(
2090            &[Accelerator::Vulkan],
2091            &Device::Default,
2092            16,
2093            16,
2094            opts(DenoisingMode::Spacial),
2095        )
2096        .unwrap();
2097
2098        d.push_frame(&frame(16, 16)).unwrap();
2099        d.push_frame(&frame(16, 16)).unwrap();
2100        let err = d.push_frame(&frame(16, 16)).expect_err("expected QueueFull");
2101        assert!(matches!(err, DenoiserError::QueueFull));
2102        assert!(!d.poisoned, "QueueFull must not poison the denoiser");
2103
2104        d.recv_frame().unwrap().expect("recv failed after QueueFull");
2105
2106        // The queue has room again, so the next push must succeed rather
2107        // than being rejected as poisoned.
2108        d.push_frame(&frame(16, 16))
2109            .expect("push after QueueFull drain should succeed, not poison");
2110    }
2111
2112    #[test]
2113    fn poisoned_denoiser_refuses_every_entry_point() {
2114        let mut d = Denoiser::create(
2115            &[Accelerator::Vulkan],
2116            &Device::Default,
2117            16,
2118            16,
2119            opts(DenoisingMode::Spacial),
2120        )
2121        .unwrap();
2122        d.poisoned = true;
2123
2124        assert!(matches!(
2125            d.push_frame(&frame(16, 16)),
2126            Err(DenoiserError::Poisoned)
2127        ));
2128        assert!(matches!(d.recv_frame(), Err(DenoiserError::Poisoned)));
2129        assert!(matches!(d.try_recv_frame(), Err(DenoiserError::Poisoned)));
2130        assert!(matches!(d.flush(|_| {}), Err(DenoiserError::Poisoned)));
2131    }
2132
2133    #[test]
2134    fn reset_stream_clears_poison() {
2135        let mut d = Denoiser::create(
2136            &[Accelerator::Vulkan],
2137            &Device::Default,
2138            16,
2139            16,
2140            opts(DenoisingMode::Spacial),
2141        )
2142        .unwrap();
2143        d.poisoned = true;
2144
2145        d.reset_stream();
2146        assert!(!d.poisoned, "reset_stream must clear the poison flag");
2147
2148        d.push_frame(&frame(16, 16))
2149            .expect("push after reset_stream should succeed");
2150    }
2151
2152    fn frame_filled(w: u32, h: u32, value: f32) -> Vec<f32> {
2153        vec![value; (w * h) as usize]
2154    }
2155
2156    /// Pushes `n` frames of the given value, receiving along the way to
2157    /// keep the in-flight pipeline below `MAX_PENDING`.
2158    fn push_n_with_drain(d: &mut Denoiser, n: usize, value: f32, out: &mut Vec<Vec<f32>>) {
2159        for _ in 0..n {
2160            loop {
2161                match d.push_frame(&frame_filled(16, 16, value)) {
2162                    Ok(()) => break,
2163                    Err(DenoiserError::QueueFull) => {
2164                        let f = d
2165                            .recv_frame()
2166                            .expect("recv ok")
2167                            .expect("queue full but recv yielded none");
2168                        out.push(f32_out(f));
2169                    },
2170                    Err(e) => panic!("unexpected push error: {e:?}"),
2171                }
2172            }
2173        }
2174    }
2175
2176    #[test]
2177    fn flush_leaves_denoiser_reusable_spatial() {
2178        let mut d = Denoiser::create(
2179            &[Accelerator::Vulkan],
2180            &Device::Default,
2181            16,
2182            16,
2183            opts(DenoisingMode::Spacial),
2184        )
2185        .unwrap();
2186
2187        let mut batch_a = Vec::new();
2188        push_n_with_drain(&mut d, 5, 0.25, &mut batch_a);
2189        d.flush(|f| batch_a.push(f32_out(f))).expect("first flush failed");
2190        assert_eq!(batch_a.len(), 5);
2191
2192        // After flush the pipeline must be empty.
2193        assert!(d.recv_frame().unwrap().is_none());
2194
2195        let mut batch_b = Vec::new();
2196        push_n_with_drain(&mut d, 5, 0.75, &mut batch_b);
2197        d.flush(|f| batch_b.push(f32_out(f)))
2198            .expect("second flush failed");
2199        assert_eq!(batch_b.len(), 5);
2200
2201        for v in batch_b.iter().flatten() {
2202            assert!((v - 0.75).abs() < 0.1, "batch_b carried state from batch_a: {v}");
2203        }
2204        for v in batch_a.iter().flatten() {
2205            assert!((v - 0.25).abs() < 0.1, "batch_a value unexpectedly drifted: {v}");
2206        }
2207    }
2208
2209    #[test]
2210    fn flush_leaves_denoiser_reusable_temporal() {
2211        let mut d = Denoiser::create(
2212            &[Accelerator::Vulkan],
2213            &Device::Default,
2214            16,
2215            16,
2216            opts(DenoisingMode::Temporal { radius: 1 }),
2217        )
2218        .unwrap();
2219
2220        let mut batch_a = Vec::new();
2221        push_n_with_drain(&mut d, 5, 0.25, &mut batch_a);
2222        d.flush(|f| batch_a.push(f32_out(f))).expect("first flush failed");
2223        assert_eq!(batch_a.len(), 5, "expected 5 frames from first batch");
2224
2225        // The temporal window must be empty after a flush, so the first
2226        // push of the new stream should not produce a pending frame.
2227        // With r=1 the window needs 3 frames before `denoise_submit`
2228        // fires.
2229        assert!(d.recv_frame().unwrap().is_none());
2230        d.push_frame(&frame_filled(16, 16, 0.75)).unwrap();
2231        assert!(
2232            d.recv_frame().unwrap().is_none(),
2233            "first push of new temporal stream should not produce output yet"
2234        );
2235
2236        // Push 4 more frames (5 total in batch B) with drain.
2237        let mut batch_b = Vec::new();
2238        push_n_with_drain(&mut d, 4, 0.75, &mut batch_b);
2239        d.flush(|f| batch_b.push(f32_out(f)))
2240            .expect("second flush failed");
2241        assert_eq!(batch_b.len(), 5, "expected 5 frames from second batch");
2242
2243        for v in batch_b.iter().flatten() {
2244            assert!((v - 0.75).abs() < 0.1, "batch_b carried state from batch_a: {v}");
2245        }
2246    }
2247
2248    #[test]
2249    fn flush_emits_exactly_n_outputs_for_small_n() {
2250        // With temporal radius R=2 the window is 5 frames. Pushing fewer
2251        // than R+1 frames means the window never fills while pushing, so
2252        // flush must still emit one output per pushed frame rather than
2253        // R+1 of them.
2254        for n in 1..=5usize {
2255            let mut d = Denoiser::create(
2256                &[Accelerator::Vulkan],
2257                &Device::Default,
2258                16,
2259                16,
2260                opts(DenoisingMode::Temporal { radius: 2 }),
2261            )
2262            .unwrap();
2263
2264            let mut out = Vec::new();
2265            push_n_with_drain(&mut d, n, 0.5, &mut out);
2266            d.flush(|f| out.push(f32_out(f))).expect("flush failed");
2267            assert_eq!(
2268                out.len(),
2269                n,
2270                "expected {n} outputs for {n} pushes, got {}",
2271                out.len()
2272            );
2273        }
2274    }
2275
2276    /// A `Pending` that was polled and then dropped has to settle its
2277    /// readback first. On the wgpu backends the first poll maps a
2278    /// staging buffer that only the finished readback unmaps, and a
2279    /// still-mapped buffer back in the pool makes the next submit on
2280    /// the device fail on cubecl's device thread, after which every
2281    /// call on that device errors.
2282    #[test]
2283    fn dropping_a_polled_pending_frame_does_not_poison_the_device() {
2284        let new = || {
2285            Denoiser::create(
2286                &[Accelerator::Vulkan],
2287                &Device::Default,
2288                64,
2289                64,
2290                opts(DenoisingMode::Spacial),
2291            )
2292            .unwrap()
2293        };
2294
2295        let mut d = new();
2296        d.push_frame(&frame(64, 64)).unwrap();
2297        // One poll starts the readback. Whether it lands on this poll
2298        // depends on the GPU, and both outcomes must survive the drop.
2299        let _ = d.try_recv_frame().unwrap();
2300        drop(d);
2301
2302        // The staging pool is per device, so a fresh denoiser on the
2303        // same device is handed the same buffers.
2304        let mut d = new();
2305        for _ in 0..4 {
2306            d.push_frame(&frame(64, 64)).unwrap();
2307            d.recv_frame()
2308                .expect("readback after a dropped polled frame should not fail")
2309                .expect("spatial mode emits one frame per push");
2310        }
2311        d.flush(|_| {}).unwrap();
2312    }
2313}