Skip to main content

av_denoise/nlmeans/motion/
mod.rs

1mod analyse;
2mod chain;
3mod compensate;
4mod confidence;
5mod pyramid;
6
7#[cfg(all(test, any(feature = "vulkan", feature = "metal")))]
8pub(crate) use analyse::mv_field_byte_offset;
9pub(crate) use analyse::{confidence_byte_offset, run_analyse, run_seeded_refine};
10#[cfg(all(test, any(feature = "vulkan", feature = "metal")))]
11pub(crate) use chain::{neighbour_idx_for_k, pair_byte_offset};
12pub(crate) use chain::{run_pair_analyse, zero_pair_slot};
13pub(crate) use compensate::run_compensate;
14pub(crate) use confidence::{run_confidence_for_neighbour, sad_noise_floor, thsad};
15use cubecl::prelude::*;
16use cubecl::server::Handle;
17pub(crate) use pyramid::{pyramid_pixels_per_frame, run_pyramid_build};
18
19/// How motion compensation is configured for a denoise pass.
20///
21/// `None` disables motion compensation entirely (zero-cost; no extra
22/// buffers are allocated). `Mvtools` enables an MVTools-inspired
23/// per-block estimator and warps neighbours toward the centre at
24/// denoise time.
25#[non_exhaustive]
26#[derive(Debug, Default, Clone, Copy, PartialEq)]
27pub enum MotionCompensationMode {
28    #[default]
29    None,
30    Mvtools {
31        /// Side length of each motion-estimation block in pixels at
32        /// the finest pyramid level.
33        blksize: u32,
34        /// Overlap between neighbouring blocks in pixels. Must be
35        /// strictly less than `blksize` so the step (`blksize - overlap`)
36        /// stays positive. Values > 0 reserve room for raised-cosine
37        /// blending in the compensate step (v1 uses a winner-block rule).
38        overlap: u32,
39        /// Pixel search radius at the *finest* pyramid level. The
40        /// coarse pass uses the same radius on the `/2` image so its
41        /// effective reach is doubled.
42        search_radius: u32,
43        /// Number of pyramid levels. `1` disables the hierarchical
44        /// coarse pass; `2` adds a `/2` coarse pass that seeds the
45        /// fine pass. Bounded by [`MAX_PYRAMID_LEVELS`].
46        pyramid_levels: u32,
47        /// How temporal MVs are estimated. `Auto` (the default) picks
48        /// the strategy from the temporal radius. Callers normally
49        /// leave this at the default. Explicit `Direct`/`Chained` are
50        /// mainly useful for pinning a variant in tests and benches.
51        estimation: MotionEstimation,
52    },
53}
54
55/// Strategy for estimating a temporal neighbour's motion vector.
56#[non_exhaustive]
57#[derive(Debug, Default, Clone, Copy, PartialEq)]
58pub enum MotionEstimation {
59    /// Resolve to `Direct` or `Chained` from the temporal radius at
60    /// denoiser construction. See [`MotionEstimation::resolve`] for the
61    /// exact rule and its empirical basis.
62    #[default]
63    Auto,
64    /// Match every neighbour directly against the centre frame at the
65    /// configured search radius. Cost scales with the temporal radius,
66    /// since each neighbour repeats the full coarse+fine search.
67    Direct,
68    /// Estimate motion between adjacent frames only, once per pushed
69    /// frame, then compose the per-step vectors into a seed for each
70    /// neighbour and correct residual drift with a small seeded
71    /// refinement search.
72    Chained {
73        /// Search radius for the seeded refinement pass, in pixels at
74        /// the finest pyramid level. Small because the composed seed
75        /// already carries most of the true displacement.
76        refine_radius: u32,
77    },
78}
79
80/// Default refinement radius for [`MotionEstimation::Chained`].
81pub const DEFAULT_REFINE_RADIUS: u32 = 2;
82
83/// Temporal radius at or above which [`MotionEstimation::Auto`]
84/// resolves to `Chained` instead of `Direct`. Below this, `Direct`
85/// tracks slightly better since the true motion still fits inside its
86/// own search window. At or above it, `Chained` stays in-window and is
87/// faster, since its reach scales with the radius instead of being
88/// capped by a fixed search window.
89pub const CHAINED_RADIUS_THRESHOLD: u32 = 3;
90
91impl MotionEstimation {
92    /// Convenience constructor for `Chained` with the library default
93    /// refinement radius.
94    pub fn chained_default() -> Self {
95        Self::Chained {
96            refine_radius: DEFAULT_REFINE_RADIUS,
97        }
98    }
99
100    /// Resolve `Auto` against the temporal radius, returning a concrete
101    /// `Direct` or `Chained` estimation. Never returns `Auto`. `Direct`
102    /// and `Chained` pass through unchanged, regardless of
103    /// `temporal_radius`. See [`CHAINED_RADIUS_THRESHOLD`] for the
104    /// threshold this applies.
105    pub fn resolve(self, temporal_radius: u32) -> Self {
106        match self {
107            Self::Auto if temporal_radius >= CHAINED_RADIUS_THRESHOLD => Self::chained_default(),
108            Self::Auto => Self::Direct,
109            other => other,
110        }
111    }
112
113    /// Reject a refinement radius the seeded fine kernel can't honour.
114    pub(crate) fn validate(&self) -> Result<(), anyhow::Error> {
115        let Self::Chained { refine_radius } = *self else {
116            return Ok(());
117        };
118
119        if refine_radius == 0 || refine_radius > MAX_SEARCH_RADIUS {
120            anyhow::bail!(
121                "motion-estimation refine_radius={refine_radius} must be in 1..={MAX_SEARCH_RADIUS}"
122            );
123        }
124
125        Ok(())
126    }
127}
128
129/// Default block size used when callers don't override it. Matches the
130/// MVTools default and lines up well with NLM's typical patch sizes.
131pub const DEFAULT_BLKSIZE: u32 = 16;
132/// Default block overlap (= `blksize / 2`).
133pub const DEFAULT_OVERLAP: u32 = 8;
134/// Default finest-level search radius. With a 2-level pyramid this
135/// reaches motion up to roughly ±12 pixels at the finest scale.
136pub const DEFAULT_SEARCH_RADIUS: u32 = 4;
137/// Default number of pyramid levels. `2` gives a single `/2` coarse
138/// pass, enough to handle most heavy-motion anime while keeping the
139/// kernel count manageable.
140pub const DEFAULT_PYRAMID_LEVELS: u32 = 2;
141
142/// Hard ceiling on `pyramid_levels`. Each extra level halves the
143/// resolution and adds an analyse-kernel launch per neighbour; 3 is
144/// already overkill for 1080p content.
145pub const MAX_PYRAMID_LEVELS: u32 = 3;
146/// Hard ceiling on `search_radius`. The analyse kernel SAD-sweeps a
147/// `(2·r + 1)²` window per block, so the cost is quadratic.
148pub const MAX_SEARCH_RADIUS: u32 = 8;
149/// Hard ceiling on `blksize`. Above this the per-block SMEM tile is
150/// uncomfortably large on RDNA-class GPUs.
151pub const MAX_BLKSIZE: u32 = 32;
152
153impl MotionCompensationMode {
154    /// Convenience constructor for `Mvtools` with library defaults.
155    ///
156    /// Pins `estimation` to `Direct` rather than the field's own
157    /// `Auto` default, so it never switches to `Chained` at larger
158    /// temporal radii the way an `Auto` configuration does.
159    pub fn mvtools_default() -> Self {
160        Self::Mvtools {
161            blksize: DEFAULT_BLKSIZE,
162            overlap: DEFAULT_OVERLAP,
163            search_radius: DEFAULT_SEARCH_RADIUS,
164            pyramid_levels: DEFAULT_PYRAMID_LEVELS,
165            estimation: MotionEstimation::Direct,
166        }
167    }
168
169    /// Whether motion compensation is active at all.
170    pub(crate) fn is_active(self) -> bool {
171        !matches!(self, Self::None)
172    }
173
174    /// Resolved estimation strategy for this mode at `temporal_radius`.
175    /// `None` when this mode isn't `Mvtools`. Never `Auto`, see
176    /// [`MotionEstimation::resolve`]. The single source every
177    /// estimation-dependent decision site (pair-ring allocation,
178    /// push-time pair-analyse gating, the submit-path dispatch branch)
179    /// goes through.
180    pub(crate) fn resolved_estimation(&self, temporal_radius: u32) -> Option<MotionEstimation> {
181        match *self {
182            Self::Mvtools { estimation, .. } => Some(estimation.resolve(temporal_radius)),
183            Self::None => None,
184        }
185    }
186
187    /// Reject parameter combinations that the kernels can't honour.
188    pub fn validate(&self) -> Result<(), anyhow::Error> {
189        let Self::Mvtools {
190            blksize,
191            overlap,
192            search_radius,
193            pyramid_levels,
194            estimation,
195        } = *self
196        else {
197            return Ok(());
198        };
199
200        if blksize < 4 {
201            anyhow::bail!("motion-compensation blksize={blksize} is too small; minimum is 4 pixels per side");
202        }
203        if blksize > MAX_BLKSIZE {
204            anyhow::bail!(
205                "motion-compensation blksize={blksize} exceeds the supported maximum ({MAX_BLKSIZE})"
206            );
207        }
208        if blksize % 2 != 0 {
209            anyhow::bail!(
210                "motion-compensation blksize={blksize} must be even so the /2 coarse level is well-defined"
211            );
212        }
213        if overlap >= blksize {
214            anyhow::bail!(
215                "motion-compensation overlap={overlap} must be strictly less than blksize ({blksize}) so step > 0"
216            );
217        }
218        if search_radius == 0 || search_radius > MAX_SEARCH_RADIUS {
219            anyhow::bail!(
220                "motion-compensation search_radius={search_radius} must be in 1..={MAX_SEARCH_RADIUS}"
221            );
222        }
223        if pyramid_levels == 0 || pyramid_levels > MAX_PYRAMID_LEVELS {
224            anyhow::bail!(
225                "motion-compensation pyramid_levels={pyramid_levels} must be in 1..={MAX_PYRAMID_LEVELS}"
226            );
227        }
228
229        estimation.validate()?;
230
231        Ok(())
232    }
233}
234
235/// Per-denoiser MC state, owned by `NlmDenoiser` when MC is active.
236///
237/// Cached at construction time so the hot dispatch path doesn't
238/// re-pattern-match the enum on every call. Holds only the fields the
239/// analyse and compensate dispatchers actually read. The full
240/// configuration lives on [`MotionCompensationMode`].
241#[derive(Debug, Clone)]
242pub(crate) struct MotionCtx {
243    pub blksize: u32,
244    pub step: u32,
245    pub search_radius: u32,
246    pub pyramid_levels: u32,
247    pub blocks_x: u32,
248    pub blocks_y: u32,
249}
250
251impl MotionCtx {
252    pub fn new(mode: MotionCompensationMode, width: u32, height: u32) -> Option<Self> {
253        let MotionCompensationMode::Mvtools {
254            blksize,
255            overlap,
256            search_radius,
257            pyramid_levels,
258            estimation: _,
259        } = mode
260        else {
261            return None;
262        };
263
264        let step = blksize - overlap;
265        let blocks_x = width.div_ceil(step).max(1);
266        let blocks_y = height.div_ceil(step).max(1);
267
268        Some(Self {
269            blksize,
270            step,
271            search_radius,
272            pyramid_levels,
273            blocks_x,
274            blocks_y,
275        })
276    }
277
278    /// MV-field slot count per neighbour. One i16x2 per block.
279    pub fn mv_slots_per_neighbour(&self) -> usize {
280        (self.blocks_x * self.blocks_y) as usize
281    }
282
283    /// Padded per-neighbour MV-field stride in bytes. Two `i32`
284    /// components (`dx`, `dy`) per block, rounded up to the GPU
285    /// storage-buffer offset alignment (32 bytes), the same convention
286    /// [`Self::confidence_bytes_per_neighbour`] uses. `wgpu` rejects a
287    /// bind-group offset that isn't a multiple of its
288    /// `min_storage_buffer_offset_alignment`, and an odd block count
289    /// leaves the unpadded 8-byte-per-block stride short of a 32-byte
290    /// multiple.
291    pub(crate) fn mv_field_bytes_per_neighbour(&self) -> u64 {
292        let blocks = (self.blocks_x as u64) * (self.blocks_y as u64);
293        (blocks * 2 * size_of::<i32>() as u64).next_multiple_of(32)
294    }
295
296    /// Padded per-neighbour confidence-buffer stride in bytes. One
297    /// `f32` per block, rounded up to the GPU storage-buffer offset
298    /// alignment (32 bytes), the same convention
299    /// [`Self::mv_field_bytes_per_neighbour`] uses for the MV field.
300    pub(crate) fn confidence_bytes_per_neighbour(&self) -> u64 {
301        let blocks = (self.blocks_x as u64) * (self.blocks_y as u64);
302        (blocks * size_of::<f32>() as u64).next_multiple_of(32)
303    }
304
305    /// i32 elements per pair-ring direction sub-array, one `(dx, dy)`
306    /// per block. This is the unpadded element count a direction's
307    /// data actually spans, used as the zero-fill length in
308    /// `zero_pair_slot` and as the input to
309    /// [`Self::pair_direction_bytes`]'s padding.
310    pub(crate) fn pair_direction_len(&self) -> u32 {
311        self.blocks_x * self.blocks_y * 2
312    }
313
314    /// Padded per-direction pair-ring stride in bytes, rounded up to
315    /// the GPU storage-buffer offset alignment (32 bytes), the same
316    /// convention [`Self::confidence_bytes_per_neighbour`] uses. Both
317    /// `pair_byte_offset` (the host-side write and zero-fill offset)
318    /// and the chain-compose kernel's own internal read stride use
319    /// this padded value, so a direction's data starts at the same
320    /// place for every reader and writer.
321    pub(crate) fn pair_direction_bytes(&self) -> u64 {
322        (self.pair_direction_len() as u64 * size_of::<i32>() as u64).next_multiple_of(32)
323    }
324
325    /// Padded per-slot pair-ring stride in bytes, both directions back
326    /// to back.
327    pub(crate) fn pair_slot_bytes(&self) -> u64 {
328        2 * self.pair_direction_bytes()
329    }
330
331    /// Padded per-direction pair-ring stride in i32 elements. The
332    /// chain-compose kernel reads the whole pair ring as one unsliced
333    /// array and strides through it with this value, matching
334    /// [`Self::pair_direction_bytes`] exactly.
335    pub(crate) fn pair_direction_stride(&self) -> u32 {
336        (self.pair_direction_bytes() / size_of::<i32>() as u64) as u32
337    }
338
339    /// Padded per-slot pair-ring stride in i32 elements, both
340    /// directions back to back.
341    pub(crate) fn pair_slot_stride(&self) -> u32 {
342        2 * self.pair_direction_stride()
343    }
344
345    /// Block geometry for the no-MC confidence pass. Uses the
346    /// library's default block size and overlap, a single pyramid
347    /// level (no coarse pass), and zero search radius (a static
348    /// per-block SAD, no motion search). Used when confidence
349    /// weighting is active but no `Mvtools` mode was configured to
350    /// derive geometry from.
351    pub(crate) fn confidence_only(width: u32, height: u32) -> Self {
352        Self::new(
353            MotionCompensationMode::Mvtools {
354                blksize: DEFAULT_BLKSIZE,
355                overlap: DEFAULT_OVERLAP,
356                search_radius: 0,
357                pyramid_levels: 1,
358                estimation: MotionEstimation::Direct,
359            },
360            width,
361            height,
362        )
363        .expect("Mvtools variant always yields Some")
364    }
365}
366
367/// Pair-ring slot count for a temporal radius, `2 * radius`.
368///
369/// The pair ring stores one adjacent-frame motion field per gap
370/// between consecutive frames in the temporal window. A window of
371/// `2 * radius + 1` frames has exactly `2 * radius` such gaps, and a
372/// gap's pair field is only ever read by composition while both its
373/// frames remain in some window, a span of exactly `2 * radius`
374/// consecutive frame pushes. Sizing the ring at `2 * radius` slots
375/// means a slot's next reuse lands exactly when its previous contents
376/// stop being needed, never before (see
377/// `NlmDenoiser::pair_slot` for the derivation this relies on).
378pub(crate) fn pair_ring_slot_count(temporal_radius: u32) -> u32 {
379    2 * temporal_radius
380}
381
382/// Build the per-frame pyramid for the slot just uploaded by
383/// `push_frame`. Always extracts level-0 luma, and also builds the
384/// downscale chain when `pyramid_levels > 1`. A thin wrapper around
385/// [`run_pyramid_build`], which already handles both cases on its own.
386#[allow(clippy::too_many_arguments)]
387pub(crate) fn build_pyramid_for_slot<R: Runtime>(
388    client: &ComputeClient<R>,
389    mc: &MotionCtx,
390    width: u32,
391    height: u32,
392    frame_count: u32,
393    slot: u32,
394    full_res: &Handle,
395    pyramid: &Handle,
396    stored_ch: u32,
397) -> Result<(), anyhow::Error> {
398    run_pyramid_build::<R>(
399        client,
400        mc,
401        width,
402        height,
403        frame_count,
404        slot,
405        full_res,
406        pyramid,
407        stored_ch,
408    )
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn none_is_inactive() {
417        let m = MotionCompensationMode::None;
418        assert!(!m.is_active());
419        m.validate().unwrap();
420    }
421
422    #[test]
423    fn mvtools_default_is_active() {
424        let m = MotionCompensationMode::mvtools_default();
425        assert!(m.is_active());
426        m.validate().unwrap();
427    }
428
429    #[test]
430    fn validate_rejects_tiny_blksize() {
431        let m = MotionCompensationMode::Mvtools {
432            blksize: 2,
433            overlap: 0,
434            search_radius: 4,
435            pyramid_levels: 2,
436            estimation: MotionEstimation::Direct,
437        };
438        assert!(m.validate().is_err());
439    }
440
441    #[test]
442    fn validate_rejects_odd_blksize() {
443        let m = MotionCompensationMode::Mvtools {
444            blksize: 9,
445            overlap: 0,
446            search_radius: 4,
447            pyramid_levels: 2,
448            estimation: MotionEstimation::Direct,
449        };
450        assert!(m.validate().is_err());
451    }
452
453    #[test]
454    fn validate_rejects_overlap_equal_to_blksize() {
455        let m = MotionCompensationMode::Mvtools {
456            blksize: 16,
457            overlap: 16,
458            search_radius: 4,
459            pyramid_levels: 2,
460            estimation: MotionEstimation::Direct,
461        };
462        // overlap == blksize would give step=0.
463        assert!(m.validate().is_err());
464    }
465
466    #[test]
467    fn validate_accepts_half_overlap() {
468        let m = MotionCompensationMode::Mvtools {
469            blksize: 16,
470            overlap: 8,
471            search_radius: 4,
472            pyramid_levels: 2,
473            estimation: MotionEstimation::Direct,
474        };
475        m.validate().unwrap();
476    }
477
478    #[test]
479    fn validate_rejects_zero_search_radius() {
480        let m = MotionCompensationMode::Mvtools {
481            blksize: 16,
482            overlap: 4,
483            search_radius: 0,
484            pyramid_levels: 2,
485            estimation: MotionEstimation::Direct,
486        };
487        assert!(m.validate().is_err());
488    }
489
490    #[test]
491    fn validate_rejects_zero_pyramid_levels() {
492        let m = MotionCompensationMode::Mvtools {
493            blksize: 16,
494            overlap: 4,
495            search_radius: 4,
496            pyramid_levels: 0,
497            estimation: MotionEstimation::Direct,
498        };
499        assert!(m.validate().is_err());
500    }
501
502    #[test]
503    fn chained_default_is_valid() {
504        let m = MotionCompensationMode::Mvtools {
505            blksize: 16,
506            overlap: 8,
507            search_radius: 4,
508            pyramid_levels: 2,
509            estimation: MotionEstimation::chained_default(),
510        };
511        m.validate().unwrap();
512        assert_eq!(
513            m,
514            MotionCompensationMode::Mvtools {
515                blksize: 16,
516                overlap: 8,
517                search_radius: 4,
518                pyramid_levels: 2,
519                estimation: MotionEstimation::Chained {
520                    refine_radius: DEFAULT_REFINE_RADIUS
521                },
522            }
523        );
524    }
525
526    #[test]
527    fn validate_rejects_zero_refine_radius() {
528        let m = MotionCompensationMode::Mvtools {
529            blksize: 16,
530            overlap: 8,
531            search_radius: 4,
532            pyramid_levels: 2,
533            estimation: MotionEstimation::Chained { refine_radius: 0 },
534        };
535        assert!(m.validate().is_err());
536    }
537
538    #[test]
539    fn validate_rejects_refine_radius_above_max() {
540        let m = MotionCompensationMode::Mvtools {
541            blksize: 16,
542            overlap: 8,
543            search_radius: 4,
544            pyramid_levels: 2,
545            estimation: MotionEstimation::Chained {
546                refine_radius: MAX_SEARCH_RADIUS + 1,
547            },
548        };
549        assert!(m.validate().is_err());
550    }
551
552    #[test]
553    fn validate_accepts_refine_radius_at_max() {
554        let m = MotionCompensationMode::Mvtools {
555            blksize: 16,
556            overlap: 8,
557            search_radius: 4,
558            pyramid_levels: 2,
559            estimation: MotionEstimation::Chained {
560                refine_radius: MAX_SEARCH_RADIUS,
561            },
562        };
563        m.validate().unwrap();
564    }
565
566    #[test]
567    fn motion_estimation_default_is_auto() {
568        assert_eq!(MotionEstimation::default(), MotionEstimation::Auto);
569    }
570
571    #[test]
572    fn resolve_auto_below_threshold_gives_direct() {
573        assert_eq!(MotionEstimation::Auto.resolve(1), MotionEstimation::Direct);
574        assert_eq!(MotionEstimation::Auto.resolve(2), MotionEstimation::Direct);
575    }
576
577    #[test]
578    fn resolve_auto_at_and_above_threshold_gives_chained_default() {
579        assert_eq!(
580            MotionEstimation::Auto.resolve(CHAINED_RADIUS_THRESHOLD),
581            MotionEstimation::chained_default()
582        );
583        assert_eq!(
584            MotionEstimation::Auto.resolve(8),
585            MotionEstimation::chained_default()
586        );
587    }
588
589    #[test]
590    fn resolve_leaves_explicit_direct_unchanged_at_every_radius() {
591        for radius in 1..=8u32 {
592            assert_eq!(MotionEstimation::Direct.resolve(radius), MotionEstimation::Direct);
593        }
594    }
595
596    #[test]
597    fn resolve_leaves_explicit_chained_unchanged_at_every_radius() {
598        let chained = MotionEstimation::Chained { refine_radius: 5 };
599        for radius in 1..=8u32 {
600            assert_eq!(chained.resolve(radius), chained);
601        }
602    }
603
604    #[test]
605    fn validate_accepts_auto() {
606        let m = MotionCompensationMode::Mvtools {
607            blksize: 16,
608            overlap: 8,
609            search_radius: 4,
610            pyramid_levels: 2,
611            estimation: MotionEstimation::Auto,
612        };
613        m.validate().unwrap();
614    }
615
616    #[test]
617    fn resolved_estimation_is_none_when_mode_is_none() {
618        assert_eq!(MotionCompensationMode::None.resolved_estimation(4), None);
619    }
620
621    #[test]
622    fn resolved_estimation_resolves_auto_from_the_mode() {
623        let m = MotionCompensationMode::Mvtools {
624            blksize: 16,
625            overlap: 8,
626            search_radius: 4,
627            pyramid_levels: 2,
628            estimation: MotionEstimation::Auto,
629        };
630        assert_eq!(m.resolved_estimation(1), Some(MotionEstimation::Direct));
631        assert_eq!(
632            m.resolved_estimation(4),
633            Some(MotionEstimation::chained_default())
634        );
635    }
636
637    #[test]
638    fn pair_ring_slot_count_is_double_radius() {
639        assert_eq!(pair_ring_slot_count(3), 6);
640        assert_eq!(pair_ring_slot_count(1), 2);
641    }
642
643    #[test]
644    fn motion_ctx_blocks_match_step() {
645        let mode = MotionCompensationMode::Mvtools {
646            blksize: 16,
647            overlap: 8,
648            search_radius: 4,
649            pyramid_levels: 2,
650            estimation: MotionEstimation::Direct,
651        };
652        let ctx = MotionCtx::new(mode, 1920, 1080).unwrap();
653        assert_eq!(ctx.step, 8);
654        assert_eq!(ctx.blocks_x, 1920u32.div_ceil(8));
655        assert_eq!(ctx.blocks_y, 1080u32.div_ceil(8));
656    }
657}