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