Skip to main content

av_denoise/
denoiser.rs

1use std::collections::VecDeque;
2
3use cubecl::Runtime;
4
5use crate::accelerate::Accelerator;
6use crate::device::Device;
7use crate::nlmeans::{ChannelMode, MotionCompensationMode, NlmDenoiser, NlmParams, Pending, PrefilterMode};
8use crate::sniff::sniff_best_accelerator;
9
10/// User-facing denoiser configuration. Build with `DenoiserOptions::builder()`.
11#[derive(Debug, Clone, bon::Builder)]
12pub struct DenoiserOptions {
13    /// Which channels of the frame to denoise.
14    #[builder(default = ChannelMode::Yuv)]
15    pub channel_mode: ChannelMode,
16    /// Spatial-only or temporal denoising.
17    #[builder(default = DenoisingMode::Spacial)]
18    pub mode: DenoisingMode,
19    /// Reference clip source for NLM weight computation.
20    #[builder(default = PrefilterMode::None)]
21    pub prefilter: PrefilterMode,
22    /// Motion-compensation mode for temporal denoising. `None`
23    /// disables MC; `Mvtools` warps temporal neighbours into spatial
24    /// alignment with the centre frame before NLM weighting. Only
25    /// takes effect when `mode` is `Temporal { .. }`.
26    #[builder(default = MotionCompensationMode::None)]
27    pub motion_compensation: MotionCompensationMode,
28    /// Override NLM tuning (search/patch radius, strength, self-weight).
29    /// `None` uses the defaults baked into [`NlmParams`].
30    pub nlm: Option<NlmTuning>,
31}
32
33/// Standard spatial or temporal-aware denoising.
34#[derive(Debug, Copy, Clone, Eq, PartialEq)]
35pub enum DenoisingMode {
36    /// Spatial-only denoising (single frame).
37    Spacial,
38    /// Temporal-aware denoising over a `2 * radius + 1` window.
39    Temporal { radius: u32 },
40}
41
42/// NLM tuning knobs. All optional; missing fields fall back to library
43/// defaults.
44#[derive(Debug, Copy, Clone)]
45pub struct NlmTuning {
46    pub search_radius: Option<u32>,
47    pub patch_radius: Option<u32>,
48    pub strength: Option<f32>,
49    pub self_weight: Option<f32>,
50}
51
52impl DenoiserOptions {
53    fn to_nlm_params(&self) -> NlmParams {
54        let mut params = NlmParams {
55            channels: self.channel_mode,
56            prefilter: self.prefilter,
57            motion_compensation: self.motion_compensation,
58            temporal_radius: match self.mode {
59                DenoisingMode::Spacial => 0,
60                DenoisingMode::Temporal { radius } => radius,
61            },
62            ..NlmParams::default()
63        };
64        if let Some(t) = self.nlm {
65            if let Some(v) = t.search_radius {
66                params.search_radius = v;
67            }
68            if let Some(v) = t.patch_radius {
69                params.patch_radius = v;
70            }
71            if let Some(v) = t.strength {
72                params.strength = v;
73            }
74            if let Some(v) = t.self_weight {
75                params.self_weight = v;
76            }
77        }
78        params
79    }
80}
81
82/// Errors surfaced from the high-level [`Denoiser`].
83#[derive(Debug, thiserror::Error)]
84pub enum DenoiserError {
85    /// A previous denoised frame hasn't been collected yet and the
86    /// internal double-buffered output slot would alias. Call
87    /// [`Denoiser::recv_frame`] or [`Denoiser::try_recv_frame`] first,
88    /// then retry the same `push_frame` call.
89    #[error("denoiser queue is full; collect the pending frame before pushing more")]
90    QueueFull,
91    /// None of the accelerators in the priority list could be initialised.
92    #[error("no accelerator from the priority list is available")]
93    NoAcceleratorAvailable,
94    /// Catch-all wrapping internal `anyhow` errors from kernel dispatch
95    /// and readback.
96    #[error(transparent)]
97    Other(#[from] anyhow::Error),
98}
99
100enum Backend {
101    #[cfg(feature = "cuda")]
102    Cuda(NlmDenoiser<cubecl::cuda::CudaRuntime>),
103    #[cfg(feature = "rocm")]
104    Rocm(NlmDenoiser<cubecl::hip::HipRuntime>),
105    #[cfg(any(feature = "vulkan", feature = "metal"))]
106    Wgpu(NlmDenoiser<cubecl::wgpu::WgpuRuntime>),
107    #[cfg(feature = "cpu")]
108    Cpu(NlmDenoiser<cubecl::cpu::CpuRuntime>),
109}
110
111enum BackendPending {
112    #[cfg(feature = "cuda")]
113    Cuda(Pending<cubecl::cuda::CudaRuntime>),
114    #[cfg(feature = "rocm")]
115    Rocm(Pending<cubecl::hip::HipRuntime>),
116    #[cfg(any(feature = "vulkan", feature = "metal"))]
117    Wgpu(Pending<cubecl::wgpu::WgpuRuntime>),
118    #[cfg(feature = "cpu")]
119    Cpu(Pending<cubecl::cpu::CpuRuntime>),
120}
121
122impl BackendPending {
123    fn wait(self) -> Result<Vec<f32>, anyhow::Error> {
124        match self {
125            #[cfg(feature = "cuda")]
126            Self::Cuda(p) => p.wait(),
127            #[cfg(feature = "rocm")]
128            Self::Rocm(p) => p.wait(),
129            #[cfg(any(feature = "vulkan", feature = "metal"))]
130            Self::Wgpu(p) => p.wait(),
131            #[cfg(feature = "cpu")]
132            Self::Cpu(p) => p.wait(),
133        }
134    }
135}
136
137/// High-level stateful denoiser. Push frames in order with
138/// [`push_frame`](Self::push_frame); collect denoised frames with
139/// [`recv_frame`](Self::recv_frame) or
140/// [`try_recv_frame`](Self::try_recv_frame); call [`flush`](Self::flush)
141/// at end-of-stream to drain any remaining temporal context.
142/// Maximum number of outstanding `Pending` readbacks the high-level
143/// [`Denoiser`] keeps in flight. Must equal the backend's output-handle
144/// count ([`crate::nlmeans::NlmDenoiser::outputs`] is `[Handle; 2]`).
145/// Exceeding this aliases the oldest pending's output handle and
146/// silently corrupts results.
147const MAX_PENDING: usize = 2;
148
149pub struct Denoiser {
150    backend: Backend,
151    pending: VecDeque<BackendPending>,
152    accelerator: Accelerator,
153    width: u32,
154    height: u32,
155    channels: u32,
156    temporal_radius: u32,
157    frames_pushed: u32,
158}
159
160impl Denoiser {
161    /// Probe each accelerator in `accelerators` in order and build a
162    /// denoiser on the first one that's available. `device` lets the
163    /// caller pick a non-default device for the chosen runtime.
164    ///
165    /// # Thread stack size
166    ///
167    /// cubecl spawns an internal per-device worker thread (named
168    /// `DS{U,D}-…`) on which GPU kernel codegen runs. It uses Rust's
169    /// default thread stack (`RUST_MIN_STACK`, or 2 MiB if unset). The
170    /// windowed NLM kernels here contain `(2·search_radius + 1)²`-times
171    /// `#[unroll]`ed bodies, so large `search_radius` (≳ 5) values can
172    /// overflow that 2 MiB default and abort the process.
173    ///
174    /// Callers planning to use `search_radius > 4` should set
175    /// `RUST_MIN_STACK` to at least 16 MiB before any cubecl thread
176    /// spawns (typically at the very top of `main`), e.g.:
177    ///
178    /// ```no_run
179    /// if std::env::var_os("RUST_MIN_STACK").is_none() {
180    ///     // SAFETY: single-threaded at startup.
181    ///     unsafe { std::env::set_var("RUST_MIN_STACK", "16777216") };
182    /// }
183    /// ```
184    pub fn create(
185        accelerators: &[Accelerator],
186        device: &Device,
187        width: u32,
188        height: u32,
189        options: DenoiserOptions,
190    ) -> Result<Self, DenoiserError> {
191        let accelerator =
192            sniff_best_accelerator(accelerators).ok_or(DenoiserError::NoAcceleratorAvailable)?;
193
194        let params = options.to_nlm_params();
195        params.validate()?;
196
197        let channels = params.channels.count();
198        let temporal_radius = params.temporal_radius;
199        let backend = build_backend(accelerator, device, params, width, height)?;
200
201        Ok(Self {
202            backend,
203            pending: VecDeque::with_capacity(MAX_PENDING),
204            accelerator,
205            width,
206            height,
207            channels,
208            temporal_radius,
209            frames_pushed: 0,
210        })
211    }
212
213    /// The accelerator picked by [`sniff_best_accelerator`].
214    pub fn selected_accelerator(&self) -> Accelerator {
215        self.accelerator
216    }
217
218    /// Width passed at construction.
219    pub fn width(&self) -> u32 {
220        self.width
221    }
222
223    /// Height passed at construction.
224    pub fn height(&self) -> u32 {
225        self.height
226    }
227
228    /// Upload one frame into the temporal window. `frame` must contain
229    /// `width * height * channels` `f32` values in `[0, 1]`. Once the
230    /// window is full and the in-flight pipeline has room, this also
231    /// kicks off the kernels for the next denoised frame.
232    ///
233    /// Up to `MAX_PENDING` outputs may be in flight simultaneously:
234    /// the GPU runs frame N+1's kernels while frame N's readback is in
235    /// flight. Returns [`DenoiserError::QueueFull`] once that ceiling
236    /// is reached; the caller must drain via [`Self::recv_frame`] before
237    /// pushing more.
238    pub fn push_frame(&mut self, frame: &[f32]) -> Result<(), DenoiserError> {
239        // After `temporal_radius` real pushes the leading-edge mirror has
240        // primed the window, so the next push will set a pending. From
241        // that point on, every push consumes a pending slot.
242        let window_full = self.frames_pushed > self.temporal_radius;
243        if window_full && self.pending.len() >= MAX_PENDING {
244            return Err(DenoiserError::QueueFull);
245        }
246
247        match &mut self.backend {
248            #[cfg(feature = "cuda")]
249            Backend::Cuda(d) => {
250                d.push_frame(frame);
251                if let Some(p) = d.denoise_submit()? {
252                    self.pending.push_back(BackendPending::Cuda(p));
253                }
254            },
255            #[cfg(feature = "rocm")]
256            Backend::Rocm(d) => {
257                d.push_frame(frame);
258                if let Some(p) = d.denoise_submit()? {
259                    self.pending.push_back(BackendPending::Rocm(p));
260                }
261            },
262            #[cfg(any(feature = "vulkan", feature = "metal"))]
263            Backend::Wgpu(d) => {
264                d.push_frame(frame);
265                if let Some(p) = d.denoise_submit()? {
266                    self.pending.push_back(BackendPending::Wgpu(p));
267                }
268            },
269            #[cfg(feature = "cpu")]
270            Backend::Cpu(d) => {
271                d.push_frame(frame);
272                if let Some(p) = d.denoise_submit()? {
273                    self.pending.push_back(BackendPending::Cpu(p));
274                }
275            },
276        }
277
278        self.frames_pushed = self.frames_pushed.saturating_add(1);
279        Ok(())
280    }
281
282    /// Block until the in-flight denoise completes and return the
283    /// denoised frame. Returns `Ok(None)` if nothing is in flight
284    /// (e.g. the temporal window isn't full yet).
285    pub fn recv_frame(&mut self) -> Result<Option<Vec<f32>>, DenoiserError> {
286        let Some(pending) = self.pending.pop_front() else {
287            return Ok(None);
288        };
289        Ok(Some(pending.wait()?))
290    }
291
292    /// Drain the in-flight denoise if one is ready. **May block
293    /// briefly** while the runtime confirms the readback has landed;
294    /// on workloads where kernels are already complete, the wait is
295    /// effectively immediate.
296    pub fn try_recv_frame(&mut self) -> Result<Option<Vec<f32>>, DenoiserError> {
297        self.recv_frame()
298    }
299
300    /// Drain in-flight frames and the trailing temporal tail (padded by duplicating
301    /// the last pushed frame), handing each produced frame to `sink`.
302    ///
303    /// On success the denoiser is left ready to accept a fresh,
304    /// independent stream of the same dimensions and parameters.
305    /// Pushing more frames after `flush` starts a new temporal window from
306    /// scratch. May be called multiple times.
307    ///
308    /// If `flush` returns `Err`, the denoiser is left in an undefined
309    /// state and should be dropped rather than reused.
310    pub fn flush(&mut self, mut sink: impl FnMut(Vec<f32>)) -> Result<(), DenoiserError> {
311        // Drain the full pending pipeline (up to MAX_PENDING frames) before
312        // submitting the trailing-tail mirrors.
313        while let Some(frame) = self.recv_frame()? {
314            sink(frame);
315        }
316
317        let pixels = (self.width * self.height) as usize;
318        let channels = self.channels as usize;
319        let scratch_cap = pixels * channels;
320
321        match &mut self.backend {
322            #[cfg(feature = "cuda")]
323            Backend::Cuda(d) => d.flush(|slice| {
324                let mut v = Vec::with_capacity(scratch_cap);
325                v.extend_from_slice(slice);
326                sink(v);
327            })?,
328            #[cfg(feature = "rocm")]
329            Backend::Rocm(d) => d.flush(|slice| {
330                let mut v = Vec::with_capacity(scratch_cap);
331                v.extend_from_slice(slice);
332                sink(v);
333            })?,
334            #[cfg(any(feature = "vulkan", feature = "metal"))]
335            Backend::Wgpu(d) => d.flush(|slice| {
336                let mut v = Vec::with_capacity(scratch_cap);
337                v.extend_from_slice(slice);
338                sink(v);
339            })?,
340            #[cfg(feature = "cpu")]
341            Backend::Cpu(d) => d.flush(|slice| {
342                let mut v = Vec::with_capacity(scratch_cap);
343                v.extend_from_slice(slice);
344                sink(v);
345            })?,
346        }
347
348        // Backend has already reset its own stream indices. Reset the
349        // outer push counter too so the next push re-arms the
350        // window-priming logic at the top of `push_frame`.
351        self.frames_pushed = 0;
352
353        Ok(())
354    }
355}
356
357fn build_backend(
358    accel: Accelerator,
359    device: &Device,
360    params: NlmParams,
361    width: u32,
362    height: u32,
363) -> Result<Backend, DenoiserError> {
364    match accel {
365        #[cfg(feature = "cuda")]
366        Accelerator::Cuda => {
367            let dev = device.to_cuda()?;
368            let client = <cubecl::cuda::CudaRuntime as Runtime>::client(&dev);
369            Ok(Backend::Cuda(NlmDenoiser::new(&client, params, width, height)))
370        },
371        #[cfg(feature = "rocm")]
372        Accelerator::Rocm => {
373            let dev = device.to_amd()?;
374            let client = <cubecl::hip::HipRuntime as Runtime>::client(&dev);
375            Ok(Backend::Rocm(NlmDenoiser::new(&client, params, width, height)))
376        },
377        #[cfg(feature = "vulkan")]
378        Accelerator::Vulkan => {
379            let dev = device.to_wgpu()?;
380            let client = <cubecl::wgpu::WgpuRuntime as Runtime>::client(&dev);
381            Ok(Backend::Wgpu(NlmDenoiser::new(&client, params, width, height)))
382        },
383        #[cfg(feature = "metal")]
384        Accelerator::Metal => {
385            let dev = device.to_wgpu()?;
386            let client = <cubecl::wgpu::WgpuRuntime as Runtime>::client(&dev);
387            Ok(Backend::Wgpu(NlmDenoiser::new(&client, params, width, height)))
388        },
389        #[cfg(feature = "cpu")]
390        Accelerator::Cpu => {
391            let dev = device.to_cpu()?;
392            let client = <cubecl::cpu::CpuRuntime as Runtime>::client(&dev);
393            Ok(Backend::Cpu(NlmDenoiser::new(&client, params, width, height)))
394        },
395        // Match-exhaustiveness placeholder for docs.rs, where `cfg(docsrs)`
396        // widens the `Accelerator` enum to include variants whose backend
397        // feature is not actually enabled. Never reached at runtime.
398        #[cfg(docsrs)]
399        #[allow(unreachable_patterns)]
400        _ => unreachable!(),
401    }
402}
403
404#[cfg(test)]
405mod options_tests {
406    use super::*;
407
408    #[test]
409    fn spatial_mode_maps_to_zero_temporal_radius() {
410        let opts = DenoiserOptions::builder()
411            .channel_mode(ChannelMode::Yuv)
412            .mode(DenoisingMode::Spacial)
413            .build();
414        let params = opts.to_nlm_params();
415
416        assert_eq!(params.temporal_radius, 0);
417        assert_eq!(params.channels, ChannelMode::Yuv);
418    }
419
420    #[test]
421    fn temporal_mode_propagates_radius() {
422        let opts = DenoiserOptions::builder()
423            .mode(DenoisingMode::Temporal { radius: 3 })
424            .build();
425        let params = opts.to_nlm_params();
426
427        assert_eq!(params.temporal_radius, 3);
428    }
429
430    #[test]
431    fn prefilter_passthrough() {
432        let opts = DenoiserOptions::builder()
433            .prefilter(PrefilterMode::Bilateral {
434                sigma_s: 3.0,
435                sigma_r: 0.02,
436            })
437            .build();
438        let params = opts.to_nlm_params();
439
440        assert!(matches!(params.prefilter, PrefilterMode::Bilateral { .. }));
441    }
442
443    #[test]
444    fn motion_compensation_passthrough() {
445        let opts = DenoiserOptions::builder()
446            .mode(DenoisingMode::Temporal { radius: 1 })
447            .motion_compensation(MotionCompensationMode::Mvtools {
448                blksize: 16,
449                overlap: 8,
450                search_radius: 4,
451                pyramid_levels: 2,
452            })
453            .build();
454        let params = opts.to_nlm_params();
455
456        assert!(matches!(
457            params.motion_compensation,
458            MotionCompensationMode::Mvtools {
459                blksize: 16,
460                overlap: 8,
461                search_radius: 4,
462                pyramid_levels: 2,
463            }
464        ));
465    }
466
467    #[test]
468    fn motion_compensation_defaults_to_none() {
469        let opts = DenoiserOptions::builder().build();
470        let params = opts.to_nlm_params();
471        assert!(matches!(params.motion_compensation, MotionCompensationMode::None));
472    }
473
474    #[test]
475    fn nlm_tuning_overrides_individual_fields() {
476        let defaults = NlmParams::default();
477        let opts = DenoiserOptions::builder()
478            .nlm(NlmTuning {
479                search_radius: Some(7),
480                patch_radius: None,
481                strength: Some(2.5),
482                self_weight: None,
483            })
484            .build();
485        let params = opts.to_nlm_params();
486
487        assert_eq!(params.search_radius, 7);
488        assert_eq!(params.patch_radius, defaults.patch_radius);
489        assert!((params.strength - 2.5).abs() < f32::EPSILON);
490        assert!((params.self_weight - defaults.self_weight).abs() < f32::EPSILON);
491    }
492}
493
494#[cfg(all(test, feature = "cpu"))]
495mod tests {
496    use super::*;
497
498    fn opts(mode: DenoisingMode) -> DenoiserOptions {
499        DenoiserOptions::builder()
500            .channel_mode(ChannelMode::Luma)
501            .mode(mode)
502            .build()
503    }
504
505    fn frame(w: u32, h: u32) -> Vec<f32> {
506        vec![0.5f32; (w * h) as usize]
507    }
508
509    #[test]
510    fn spatial_denoise_roundtrip() {
511        let mut d = Denoiser::create(
512            &[Accelerator::Cpu],
513            &Device::Default,
514            16,
515            16,
516            opts(DenoisingMode::Spacial),
517        )
518        .expect("denoiser construction failed");
519        assert_eq!(d.selected_accelerator(), Accelerator::Cpu);
520
521        d.push_frame(&frame(16, 16)).expect("push failed");
522        let out = d.recv_frame().expect("recv failed").expect("no frame");
523        assert_eq!(out.len(), 16 * 16);
524    }
525
526    #[test]
527    fn invalid_params_surface_as_error() {
528        let bad = DenoiserOptions::builder()
529            .nlm(NlmTuning {
530                search_radius: None,
531                patch_radius: None,
532                strength: Some(0.0),
533                self_weight: None,
534            })
535            .build();
536        let result = Denoiser::create(&[Accelerator::Cpu], &Device::Default, 16, 16, bad);
537
538        match result {
539            Err(DenoiserError::Other(_)) => {},
540            Err(other) => panic!("expected DenoiserError::Other, got {other:?}"),
541            Ok(_) => panic!("expected validation error, got Ok"),
542        }
543    }
544
545    #[test]
546    fn push_after_pending_returns_queue_full() {
547        let mut d = Denoiser::create(
548            &[Accelerator::Cpu],
549            &Device::Default,
550            16,
551            16,
552            opts(DenoisingMode::Spacial),
553        )
554        .unwrap();
555
556        // Depth-2 pipeline: the first two pushes both submit successfully
557        // (output handles are double-buffered). The third would alias the
558        // oldest pending's output slot and is rejected with QueueFull.
559        d.push_frame(&frame(16, 16)).unwrap();
560        d.push_frame(&frame(16, 16)).unwrap();
561        let err = d.push_frame(&frame(16, 16)).expect_err("expected QueueFull");
562        assert!(matches!(err, DenoiserError::QueueFull));
563
564        let out = d.recv_frame().unwrap().unwrap();
565        assert_eq!(out.len(), 16 * 16);
566
567        // After draining one slot the next push must succeed.
568        d.push_frame(&frame(16, 16)).expect("push after drain failed");
569    }
570
571    fn frame_filled(w: u32, h: u32, value: f32) -> Vec<f32> {
572        vec![value; (w * h) as usize]
573    }
574
575    /// Push `n` frames of the given value, interleaving `recv_frame` to keep
576    /// the in-flight pipeline below `MAX_PENDING`.
577    fn push_n_with_drain(d: &mut Denoiser, n: usize, value: f32, out: &mut Vec<Vec<f32>>) {
578        for _ in 0..n {
579            loop {
580                match d.push_frame(&frame_filled(16, 16, value)) {
581                    Ok(()) => break,
582                    Err(DenoiserError::QueueFull) => {
583                        let f = d
584                            .recv_frame()
585                            .expect("recv ok")
586                            .expect("queue full but recv yielded none");
587                        out.push(f);
588                    },
589                    Err(e) => panic!("unexpected push error: {e:?}"),
590                }
591            }
592        }
593    }
594
595    #[test]
596    fn flush_leaves_denoiser_reusable_spatial() {
597        let mut d = Denoiser::create(
598            &[Accelerator::Cpu],
599            &Device::Default,
600            16,
601            16,
602            opts(DenoisingMode::Spacial),
603        )
604        .unwrap();
605
606        let mut batch_a = Vec::new();
607        push_n_with_drain(&mut d, 5, 0.25, &mut batch_a);
608        d.flush(|f| batch_a.push(f)).expect("first flush failed");
609        assert_eq!(batch_a.len(), 5);
610
611        // After flush the pipeline must be empty.
612        assert!(d.recv_frame().unwrap().is_none());
613
614        let mut batch_b = Vec::new();
615        push_n_with_drain(&mut d, 5, 0.75, &mut batch_b);
616        d.flush(|f| batch_b.push(f)).expect("second flush failed");
617        assert_eq!(batch_b.len(), 5);
618
619        for v in batch_b.iter().flatten() {
620            assert!((v - 0.75).abs() < 0.1, "batch_b carried state from batch_a: {v}");
621        }
622        for v in batch_a.iter().flatten() {
623            assert!((v - 0.25).abs() < 0.1, "batch_a value unexpectedly drifted: {v}");
624        }
625    }
626
627    #[test]
628    fn flush_leaves_denoiser_reusable_temporal() {
629        let mut d = Denoiser::create(
630            &[Accelerator::Cpu],
631            &Device::Default,
632            16,
633            16,
634            opts(DenoisingMode::Temporal { radius: 1 }),
635        )
636        .unwrap();
637
638        let mut batch_a = Vec::new();
639        push_n_with_drain(&mut d, 5, 0.25, &mut batch_a);
640        d.flush(|f| batch_a.push(f)).expect("first flush failed");
641        assert_eq!(batch_a.len(), 5, "expected 5 frames from first batch");
642
643        // The temporal window must be empty after flush: the first push of
644        // the new stream should not yield a pending immediately. With r=1
645        // the window needs 3 frames before `denoise_submit` fires.
646        assert!(d.recv_frame().unwrap().is_none());
647        d.push_frame(&frame_filled(16, 16, 0.75)).unwrap();
648        assert!(
649            d.recv_frame().unwrap().is_none(),
650            "first push of new temporal stream should not produce output yet"
651        );
652
653        // Push 4 more frames (5 total in batch B) with drain.
654        let mut batch_b = Vec::new();
655        push_n_with_drain(&mut d, 4, 0.75, &mut batch_b);
656        d.flush(|f| batch_b.push(f)).expect("second flush failed");
657        assert_eq!(batch_b.len(), 5, "expected 5 frames from second batch");
658
659        for v in batch_b.iter().flatten() {
660            assert!((v - 0.75).abs() < 0.1, "batch_b carried state from batch_a: {v}");
661        }
662    }
663
664    #[test]
665    fn flush_emits_exactly_n_outputs_for_small_n() {
666        // With temporal radius R=2 the window is 5 frames. Pushing fewer
667        // than R+1 frames means the window never fills during pushes —
668        // flush must still emit exactly N outputs (one per pushed frame),
669        // not R+1.
670        for n in 1..=5usize {
671            let mut d = Denoiser::create(
672                &[Accelerator::Cpu],
673                &Device::Default,
674                16,
675                16,
676                opts(DenoisingMode::Temporal { radius: 2 }),
677            )
678            .unwrap();
679
680            let mut out = Vec::new();
681            push_n_with_drain(&mut d, n, 0.5, &mut out);
682            d.flush(|f| out.push(f)).expect("flush failed");
683            assert_eq!(
684                out.len(),
685                n,
686                "expected {n} outputs for {n} pushes, got {}",
687                out.len()
688            );
689        }
690    }
691}