Skip to main content

av_denoise_core/nlmeans/motion/
mod.rs

1//! Following motion between frames so temporal denoising stays sharp.
2//!
3//! Temporal denoising averages a pixel with the same position in nearby
4//! frames. When the camera or the content moves, that position holds
5//! different content in each frame, and averaging it blurs the moving
6//! parts.
7//!
8//! This module works out where each block of pixels moved to, then
9//! shifts the neighbouring frames back into line with the current one
10//! before the denoising weights are computed.
11//!
12//! # How a frame is tracked
13//!
14//! `pyramid` builds a stack of progressively smaller copies of the luma
15//! plane. A search on a small copy finds large movements cheaply, and
16//! the answer then seeds a short search at full resolution.
17//!
18//! `analyse` runs that search. `chain` handles distant neighbours by
19//! measuring motion between adjacent frames and joining the results,
20//! which reaches further than any single search window.
21//!
22//! `confidence` scores how well each block actually matched, so a block
23//! that was occluded or changed can be held back rather than blurred in.
24//!
25//! `compensate` applies the finished motion field to a frame.
26
27mod analyse;
28mod chain;
29mod compensate;
30mod confidence;
31mod pyramid;
32
33#[cfg(all(test, any(feature = "vulkan", feature = "metal")))]
34pub(crate) use analyse::mv_field_byte_offset;
35pub(crate) use analyse::{confidence_byte_offset, run_analyse, run_seeded_refine};
36#[cfg(all(test, any(feature = "vulkan", feature = "metal")))]
37pub(crate) use chain::{neighbour_idx_for_k, pair_byte_offset};
38pub(crate) use chain::{run_pair_analyse, zero_pair_slot};
39pub(crate) use compensate::run_compensate;
40pub(crate) use confidence::{run_confidence_for_neighbour, sad_noise_floor, thsad};
41use cubecl::prelude::*;
42use cubecl::server::Handle;
43pub(crate) use pyramid::{pyramid_pixels_per_frame, run_pyramid_build};
44
45use crate::nlmeans::align::StorageAlign;
46
47/// The motion search's tuning, for a denoiser that always tracks motion.
48///
49/// [`MotionCompensationMode::Mvtools`] carries the same five values for
50/// a denoiser that can also turn motion compensation off.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct MotionSearch {
53    /// The side length of each motion-search block, in pixels at the
54    /// finest pyramid level.
55    pub blksize: u32,
56    /// How many pixels neighbouring blocks overlap.
57    ///
58    /// This has to be strictly below `blksize`, so the step between
59    /// blocks stays positive.
60    pub overlap: u32,
61    /// The search radius in pixels at the finest pyramid level.
62    ///
63    /// The coarse pass uses the same radius on a half-size image, so
64    /// its real reach is twice as far.
65    pub search_radius: u32,
66    /// How many levels the pyramid has.
67    ///
68    /// `1` means a single full-resolution search. `2` adds a half-size
69    /// coarse pass that seeds the fine one. The maximum is
70    /// [`MAX_PYRAMID_LEVELS`].
71    pub pyramid_levels: u32,
72    /// How motion toward each temporal neighbour is estimated.
73    pub estimation: MotionEstimation,
74}
75
76impl Default for MotionSearch {
77    fn default() -> Self {
78        Self {
79            blksize: 16,
80            overlap: 8,
81            search_radius: 4,
82            pyramid_levels: 2,
83            estimation: MotionEstimation::Auto,
84        }
85    }
86}
87
88impl From<MotionSearch> for MotionCompensationMode {
89    fn from(search: MotionSearch) -> Self {
90        Self::Mvtools {
91            blksize: search.blksize,
92            overlap: search.overlap,
93            search_radius: search.search_radius,
94            pyramid_levels: search.pyramid_levels,
95            estimation: search.estimation,
96        }
97    }
98}
99
100/// How motion compensation is set up for a denoise pass.
101#[non_exhaustive]
102#[derive(Debug, Default, Clone, Copy, PartialEq)]
103pub enum MotionCompensationMode {
104    /// Motion compensation is off, and no extra buffers are allocated.
105    #[default]
106    None,
107    /// An estimator inspired by MVTools tracks each block, and the
108    /// neighbouring frames are shifted toward the centre frame at
109    /// denoise time.
110    Mvtools {
111        /// The side length of each motion-search block, in pixels at
112        /// the finest pyramid level.
113        blksize: u32,
114        /// How many pixels neighbouring blocks overlap.
115        ///
116        /// This has to be strictly below `blksize`, so the step between
117        /// blocks stays positive.
118        ///
119        /// Anything above 0 leaves room for the raised-cosine blend in
120        /// the compensate step, which currently uses a
121        /// winner-takes-all rule instead.
122        overlap: u32,
123        /// The search radius in pixels at the finest pyramid level.
124        ///
125        /// The coarse pass uses the same radius on a half-size image, so
126        /// its real reach is twice as far.
127        search_radius: u32,
128        /// How many levels the pyramid has.
129        ///
130        /// `1` means a single full-resolution search. `2` adds a
131        /// half-size coarse pass that seeds the fine one. The maximum is
132        /// [`MAX_PYRAMID_LEVELS`].
133        pyramid_levels: u32,
134        /// How motion toward each temporal neighbour is estimated.
135        ///
136        /// `Auto`, the default, picks a strategy from the temporal
137        /// radius and is what callers normally want. Naming `Direct` or
138        /// `Chained` is mostly useful for pinning one strategy in tests
139        /// and benches.
140        estimation: MotionEstimation,
141    },
142}
143
144/// How motion toward a temporal neighbour is estimated.
145#[non_exhaustive]
146#[derive(Debug, Default, Clone, Copy, PartialEq)]
147pub enum MotionEstimation {
148    /// Picks `Direct` or `Chained` from the temporal radius when the
149    /// denoiser is built.
150    ///
151    /// [`MotionEstimation::resolve`] describes the rule and where it
152    /// came from.
153    #[default]
154    Auto,
155    /// Matches every neighbour against the centre frame directly, at the
156    /// configured search radius.
157    ///
158    /// The cost grows with the temporal radius, because each neighbour
159    /// repeats the whole coarse and fine search.
160    Direct,
161    /// Measures motion only between adjacent frames, once per pushed
162    /// frame.
163    ///
164    /// Those per-step vectors are then joined into a seed for each
165    /// neighbour, and a small seeded search cleans up whatever drift is
166    /// left.
167    Chained {
168        /// The search radius for the seeded refinement pass, in pixels
169        /// at the finest pyramid level.
170        ///
171        /// It can be small, because the joined seed already carries most
172        /// of the real movement.
173        refine_radius: u32,
174    },
175}
176
177/// The default refinement radius for [`MotionEstimation::Chained`].
178pub const DEFAULT_REFINE_RADIUS: u32 = 2;
179
180/// The temporal radius at which [`MotionEstimation::Auto`] switches from
181/// `Direct` to `Chained`.
182///
183/// Below this, `Direct` tracks slightly better, because the real motion
184/// still fits inside its own search window.
185///
186/// At or above it, `Chained` both stays inside its window and runs
187/// faster, because its reach grows with the radius rather than being
188/// capped by a fixed window.
189pub const CHAINED_RADIUS_THRESHOLD: u32 = 3;
190
191impl MotionEstimation {
192    /// Builds a `Chained` estimation with the library's default
193    /// refinement radius.
194    pub fn chained_default() -> Self {
195        Self::Chained {
196            refine_radius: DEFAULT_REFINE_RADIUS,
197        }
198    }
199
200    /// Resolves `Auto` against the temporal radius, always returning a
201    /// concrete `Direct` or `Chained`.
202    ///
203    /// `Direct` and `Chained` pass through unchanged whatever the
204    /// radius. [`CHAINED_RADIUS_THRESHOLD`] is where the switch happens.
205    pub fn resolve(self, temporal_radius: u32) -> Self {
206        match self {
207            Self::Auto if temporal_radius >= CHAINED_RADIUS_THRESHOLD => Self::chained_default(),
208            Self::Auto => Self::Direct,
209            other => other,
210        }
211    }
212
213    /// Rejects a refinement radius the seeded fine kernel cannot honour.
214    pub(crate) fn validate(&self) -> Result<(), anyhow::Error> {
215        let Self::Chained { refine_radius } = *self else {
216            return Ok(());
217        };
218
219        if refine_radius == 0 || refine_radius > MAX_SEARCH_RADIUS {
220            anyhow::bail!(
221                "motion-estimation refine_radius={refine_radius} must be in 1..={MAX_SEARCH_RADIUS}"
222            );
223        }
224
225        Ok(())
226    }
227}
228
229/// The default block size, matching MVTools and lining up well with the
230/// patch sizes NLM typically uses.
231pub const DEFAULT_BLKSIZE: u32 = 16;
232
233/// The default block overlap, which is half the default block size.
234pub const DEFAULT_OVERLAP: u32 = 8;
235
236/// The default search radius at the finest level.
237///
238/// With a two-level pyramid this reaches motion of roughly 12 pixels at
239/// full resolution.
240pub const DEFAULT_SEARCH_RADIUS: u32 = 4;
241
242/// The default number of pyramid levels.
243///
244/// Two levels give a single half-size coarse pass, which handles most
245/// heavy-motion anime while keeping the number of kernel launches down.
246pub const DEFAULT_PYRAMID_LEVELS: u32 = 2;
247
248/// The hard ceiling on `pyramid_levels`.
249///
250/// Each extra level halves the resolution again and adds a kernel launch
251/// per neighbour. Three is already more than 1080p content needs.
252pub const MAX_PYRAMID_LEVELS: u32 = 3;
253
254/// The hard ceiling on `search_radius`.
255///
256/// The analyse kernel scores a `(2 * radius + 1)^2` window per block, so
257/// the cost grows with the square of the radius.
258pub const MAX_SEARCH_RADIUS: u32 = 8;
259
260/// The hard ceiling on `blksize`.
261///
262/// Above this the per-block shared-memory tile grows uncomfortably large
263/// on RDNA-class GPUs.
264pub const MAX_BLKSIZE: u32 = 32;
265
266impl MotionCompensationMode {
267    /// Builds an `Mvtools` mode from the library defaults.
268    ///
269    /// This pins `estimation` to `Direct` rather than the field's own
270    /// `Auto` default, so it never switches to `Chained` at larger
271    /// temporal radii the way an `Auto` configuration would.
272    pub fn mvtools_default() -> Self {
273        Self::Mvtools {
274            blksize: DEFAULT_BLKSIZE,
275            overlap: DEFAULT_OVERLAP,
276            search_radius: DEFAULT_SEARCH_RADIUS,
277            pyramid_levels: DEFAULT_PYRAMID_LEVELS,
278            estimation: MotionEstimation::Direct,
279        }
280    }
281
282    /// Whether motion compensation is active at all.
283    pub(crate) fn is_active(self) -> bool {
284        !matches!(self, Self::None)
285    }
286
287    /// The estimation strategy this mode resolves to at
288    /// `temporal_radius`.
289    ///
290    /// Returns `None` when the mode is not `Mvtools`, and never returns
291    /// `Auto`. See [`MotionEstimation::resolve`].
292    ///
293    /// Every decision that depends on the strategy goes through here,
294    /// including pair-ring allocation, whether the push-time pair
295    /// analyse runs, and which branch the submit path takes.
296    pub(crate) fn resolved_estimation(&self, temporal_radius: u32) -> Option<MotionEstimation> {
297        match *self {
298            Self::Mvtools { estimation, .. } => Some(estimation.resolve(temporal_radius)),
299            Self::None => None,
300        }
301    }
302
303    /// Rejects parameter combinations the kernels cannot honour.
304    pub fn validate(&self) -> Result<(), anyhow::Error> {
305        let Self::Mvtools {
306            blksize,
307            overlap,
308            search_radius,
309            pyramid_levels,
310            estimation,
311        } = *self
312        else {
313            return Ok(());
314        };
315
316        if blksize < 4 {
317            anyhow::bail!(
318                "motion-compensation blksize={blksize} is too small, the minimum is 4 pixels per side"
319            );
320        }
321        if blksize > MAX_BLKSIZE {
322            anyhow::bail!(
323                "motion-compensation blksize={blksize} exceeds the supported maximum of {MAX_BLKSIZE}"
324            );
325        }
326        if blksize % 2 != 0 {
327            anyhow::bail!(
328                "motion-compensation blksize={blksize} must be even so the /2 coarse level is well-defined"
329            );
330        }
331        if overlap >= blksize {
332            anyhow::bail!(
333                "motion-compensation overlap={overlap} must be strictly less than blksize, \
334                 which is {blksize}, so the step between blocks stays positive"
335            );
336        }
337        if search_radius == 0 || search_radius > MAX_SEARCH_RADIUS {
338            anyhow::bail!(
339                "motion-compensation search_radius={search_radius} must be in 1..={MAX_SEARCH_RADIUS}"
340            );
341        }
342        if pyramid_levels == 0 || pyramid_levels > MAX_PYRAMID_LEVELS {
343            anyhow::bail!(
344                "motion-compensation pyramid_levels={pyramid_levels} must be in 1..={MAX_PYRAMID_LEVELS}"
345            );
346        }
347
348        estimation.validate()?;
349
350        Ok(())
351    }
352}
353
354/// The motion-compensation state a `NlmDenoiser` holds while motion
355/// compensation is active.
356///
357/// It is worked out once at construction, so the hot dispatch path never
358/// has to re-read the configuration enum.
359///
360/// Only the fields the analyse and compensate dispatchers use live here.
361/// The full configuration stays on [`MotionCompensationMode`].
362#[derive(Debug, Clone)]
363pub(crate) struct MotionCtx {
364    pub blksize: u32,
365    pub step: u32,
366    pub search_radius: u32,
367    pub pyramid_levels: u32,
368    pub blocks_x: u32,
369    pub blocks_y: u32,
370    /// The alignment every buffer this context slices per slot has to
371    /// respect, meaning the motion field, the confidence buffer, the
372    /// pair ring, and the pyramid.
373    ///
374    /// It is read from the runtime. See [`StorageAlign`].
375    pub align: StorageAlign,
376}
377
378impl MotionCtx {
379    pub fn new(mode: MotionCompensationMode, width: u32, height: u32, align: StorageAlign) -> Option<Self> {
380        let MotionCompensationMode::Mvtools {
381            blksize,
382            overlap,
383            search_radius,
384            pyramid_levels,
385            estimation: _,
386        } = mode
387        else {
388            return None;
389        };
390
391        let step = blksize - overlap;
392        let blocks_x = width.div_ceil(step).max(1);
393        let blocks_y = height.div_ceil(step).max(1);
394
395        Some(Self {
396            blksize,
397            step,
398            search_radius,
399            pyramid_levels,
400            blocks_x,
401            blocks_y,
402            align,
403        })
404    }
405
406    /// How many motion-field slots each neighbour needs, which is one
407    /// per block.
408    pub fn mv_slots_per_neighbour(&self) -> usize {
409        (self.blocks_x * self.blocks_y) as usize
410    }
411
412    /// The padded per-neighbour motion-field stride in bytes.
413    ///
414    /// Each block stores two `i32` components, and the total is rounded
415    /// up to the runtime's buffer-binding alignment. This is the same
416    /// convention [`Self::confidence_bytes_per_neighbour`] uses.
417    ///
418    /// The padding matters because wgpu rejects a bind-group offset that
419    /// is not a multiple of its `min_storage_buffer_offset_alignment`,
420    /// and an odd block count leaves the unpadded stride short of that
421    /// boundary.
422    pub(crate) fn mv_field_bytes_per_neighbour(&self) -> u64 {
423        let blocks = (self.blocks_x as u64) * (self.blocks_y as u64);
424        self.align.pad_bytes(blocks * 2 * size_of::<i32>() as u64)
425    }
426
427    /// The padded per-neighbour confidence-buffer stride in bytes.
428    ///
429    /// Each block stores one `f32`, rounded up to the runtime's
430    /// buffer-binding alignment the same way
431    /// [`Self::mv_field_bytes_per_neighbour`] rounds the motion field.
432    pub(crate) fn confidence_bytes_per_neighbour(&self) -> u64 {
433        let blocks = (self.blocks_x as u64) * (self.blocks_y as u64);
434        self.align.pad_bytes(blocks * size_of::<f32>() as u64)
435    }
436
437    /// How many `i32` elements one direction of a pair-ring slot holds,
438    /// which is two per block.
439    ///
440    /// This is the unpadded count of the data itself. It is the
441    /// zero-fill length in `zero_pair_slot` and the input
442    /// [`Self::pair_direction_bytes`] pads.
443    pub(crate) fn pair_direction_len(&self) -> u32 {
444        self.blocks_x * self.blocks_y * 2
445    }
446
447    /// The padded per-direction pair-ring stride in bytes, rounded up to
448    /// the runtime's buffer-binding alignment the same way
449    /// [`Self::confidence_bytes_per_neighbour`] rounds its own stride.
450    ///
451    /// Both `pair_byte_offset`, which the host writes and zero-fills
452    /// at, and the chain-compose kernel's internal read stride use this
453    /// padded value, so a direction's data starts in the same place for
454    /// every reader and writer.
455    pub(crate) fn pair_direction_bytes(&self) -> u64 {
456        self.align
457            .pad_bytes(self.pair_direction_len() as u64 * size_of::<i32>() as u64)
458    }
459
460    /// The padded per-slot pair-ring stride in bytes, covering both
461    /// directions back to back.
462    pub(crate) fn pair_slot_bytes(&self) -> u64 {
463        2 * self.pair_direction_bytes()
464    }
465
466    /// The padded per-direction pair-ring stride in `i32` elements.
467    ///
468    /// The chain-compose kernel reads the whole pair ring as one array
469    /// and steps through it with this value, which matches
470    /// [`Self::pair_direction_bytes`] exactly.
471    pub(crate) fn pair_direction_stride(&self) -> u32 {
472        (self.pair_direction_bytes() / size_of::<i32>() as u64) as u32
473    }
474
475    /// The padded per-slot pair-ring stride in `i32` elements, covering
476    /// both directions back to back.
477    pub(crate) fn pair_slot_stride(&self) -> u32 {
478        2 * self.pair_direction_stride()
479    }
480
481    /// The block geometry for a confidence pass with no motion
482    /// compensation.
483    ///
484    /// It uses the library's default block size and overlap, one pyramid
485    /// level so there is no coarse pass, and a search radius of zero, so
486    /// each block is scored where it stands with no motion search at
487    /// all.
488    ///
489    /// This is what runs when confidence weighting is on but no
490    /// `Mvtools` mode was configured to take geometry from.
491    pub(crate) fn confidence_only(width: u32, height: u32, align: StorageAlign) -> Self {
492        Self::new(
493            MotionCompensationMode::Mvtools {
494                blksize: DEFAULT_BLKSIZE,
495                overlap: DEFAULT_OVERLAP,
496                search_radius: 0,
497                pyramid_levels: 1,
498                estimation: MotionEstimation::Direct,
499            },
500            width,
501            height,
502            align,
503        )
504        .expect("Mvtools variant always yields Some")
505    }
506}
507
508/// How many slots the pair ring needs for a given temporal radius.
509///
510/// The pair ring stores one adjacent-frame motion field per gap between
511/// consecutive frames in the temporal window. A window of
512/// `2 * radius + 1` frames has exactly `2 * radius` gaps.
513///
514/// A gap's field is only read while both of its frames are still inside
515/// some window, which lasts exactly `2 * radius` frame pushes.
516///
517/// Sizing the ring to match means a slot is reused precisely when its
518/// old contents stop being needed, and never sooner.
519/// `NlmDenoiser::pair_slot` works this out in full.
520pub(crate) fn pair_ring_slot_count(temporal_radius: u32) -> u32 {
521    2 * temporal_radius
522}
523
524/// Builds the pyramid for the slot `push_frame` just uploaded.
525///
526/// Level 0 luma is always extracted, and the smaller levels follow when
527/// `pyramid_levels` is above 1.
528///
529/// This is a thin wrapper around [`run_pyramid_build`], which already
530/// handles both cases itself.
531#[expect(
532    clippy::too_many_arguments,
533    reason = "the dispatch threads through every buffer and shape the kernel binds"
534)]
535pub(crate) fn build_pyramid_for_slot<R: Runtime>(
536    client: &ComputeClient<R>,
537    mc: &MotionCtx,
538    width: u32,
539    height: u32,
540    frame_count: u32,
541    slot: u32,
542    full_res: &Handle,
543    pyramid: &Handle,
544    stored_ch: u32,
545) -> Result<(), anyhow::Error> {
546    run_pyramid_build::<R>(
547        client,
548        mc,
549        width,
550        height,
551        frame_count,
552        slot,
553        full_res,
554        pyramid,
555        stored_ch,
556    )
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn none_is_inactive() {
565        let m = MotionCompensationMode::None;
566        assert!(!m.is_active());
567        m.validate().unwrap();
568    }
569
570    #[test]
571    fn mvtools_default_is_active() {
572        let m = MotionCompensationMode::mvtools_default();
573        assert!(m.is_active());
574        m.validate().unwrap();
575    }
576
577    #[test]
578    fn validate_rejects_tiny_blksize() {
579        let m = MotionCompensationMode::Mvtools {
580            blksize: 2,
581            overlap: 0,
582            search_radius: 4,
583            pyramid_levels: 2,
584            estimation: MotionEstimation::Direct,
585        };
586        assert!(m.validate().is_err());
587    }
588
589    #[test]
590    fn validate_rejects_odd_blksize() {
591        let m = MotionCompensationMode::Mvtools {
592            blksize: 9,
593            overlap: 0,
594            search_radius: 4,
595            pyramid_levels: 2,
596            estimation: MotionEstimation::Direct,
597        };
598        assert!(m.validate().is_err());
599    }
600
601    #[test]
602    fn validate_rejects_overlap_equal_to_blksize() {
603        let m = MotionCompensationMode::Mvtools {
604            blksize: 16,
605            overlap: 16,
606            search_radius: 4,
607            pyramid_levels: 2,
608            estimation: MotionEstimation::Direct,
609        };
610        // An overlap equal to blksize would leave a step of 0.
611        assert!(m.validate().is_err());
612    }
613
614    #[test]
615    fn validate_accepts_half_overlap() {
616        let m = MotionCompensationMode::Mvtools {
617            blksize: 16,
618            overlap: 8,
619            search_radius: 4,
620            pyramid_levels: 2,
621            estimation: MotionEstimation::Direct,
622        };
623        m.validate().unwrap();
624    }
625
626    #[test]
627    fn validate_rejects_zero_search_radius() {
628        let m = MotionCompensationMode::Mvtools {
629            blksize: 16,
630            overlap: 4,
631            search_radius: 0,
632            pyramid_levels: 2,
633            estimation: MotionEstimation::Direct,
634        };
635        assert!(m.validate().is_err());
636    }
637
638    #[test]
639    fn validate_rejects_zero_pyramid_levels() {
640        let m = MotionCompensationMode::Mvtools {
641            blksize: 16,
642            overlap: 4,
643            search_radius: 4,
644            pyramid_levels: 0,
645            estimation: MotionEstimation::Direct,
646        };
647        assert!(m.validate().is_err());
648    }
649
650    #[test]
651    fn chained_default_is_valid() {
652        let m = MotionCompensationMode::Mvtools {
653            blksize: 16,
654            overlap: 8,
655            search_radius: 4,
656            pyramid_levels: 2,
657            estimation: MotionEstimation::chained_default(),
658        };
659        m.validate().unwrap();
660        assert_eq!(
661            m,
662            MotionCompensationMode::Mvtools {
663                blksize: 16,
664                overlap: 8,
665                search_radius: 4,
666                pyramid_levels: 2,
667                estimation: MotionEstimation::Chained {
668                    refine_radius: DEFAULT_REFINE_RADIUS
669                },
670            }
671        );
672    }
673
674    #[test]
675    fn validate_rejects_zero_refine_radius() {
676        let m = MotionCompensationMode::Mvtools {
677            blksize: 16,
678            overlap: 8,
679            search_radius: 4,
680            pyramid_levels: 2,
681            estimation: MotionEstimation::Chained { refine_radius: 0 },
682        };
683        assert!(m.validate().is_err());
684    }
685
686    #[test]
687    fn validate_rejects_refine_radius_above_max() {
688        let m = MotionCompensationMode::Mvtools {
689            blksize: 16,
690            overlap: 8,
691            search_radius: 4,
692            pyramid_levels: 2,
693            estimation: MotionEstimation::Chained {
694                refine_radius: MAX_SEARCH_RADIUS + 1,
695            },
696        };
697        assert!(m.validate().is_err());
698    }
699
700    #[test]
701    fn validate_accepts_refine_radius_at_max() {
702        let m = MotionCompensationMode::Mvtools {
703            blksize: 16,
704            overlap: 8,
705            search_radius: 4,
706            pyramid_levels: 2,
707            estimation: MotionEstimation::Chained {
708                refine_radius: MAX_SEARCH_RADIUS,
709            },
710        };
711        m.validate().unwrap();
712    }
713
714    #[test]
715    fn motion_estimation_default_is_auto() {
716        assert_eq!(MotionEstimation::default(), MotionEstimation::Auto);
717    }
718
719    #[test]
720    fn resolve_auto_below_threshold_gives_direct() {
721        assert_eq!(MotionEstimation::Auto.resolve(1), MotionEstimation::Direct);
722        assert_eq!(MotionEstimation::Auto.resolve(2), MotionEstimation::Direct);
723    }
724
725    #[test]
726    fn resolve_auto_at_and_above_threshold_gives_chained_default() {
727        assert_eq!(
728            MotionEstimation::Auto.resolve(CHAINED_RADIUS_THRESHOLD),
729            MotionEstimation::chained_default()
730        );
731        assert_eq!(
732            MotionEstimation::Auto.resolve(8),
733            MotionEstimation::chained_default()
734        );
735    }
736
737    #[test]
738    fn resolve_leaves_explicit_direct_unchanged_at_every_radius() {
739        for radius in 1..=8u32 {
740            assert_eq!(MotionEstimation::Direct.resolve(radius), MotionEstimation::Direct);
741        }
742    }
743
744    #[test]
745    fn resolve_leaves_explicit_chained_unchanged_at_every_radius() {
746        let chained = MotionEstimation::Chained { refine_radius: 5 };
747        for radius in 1..=8u32 {
748            assert_eq!(chained.resolve(radius), chained);
749        }
750    }
751
752    #[test]
753    fn validate_accepts_auto() {
754        let m = MotionCompensationMode::Mvtools {
755            blksize: 16,
756            overlap: 8,
757            search_radius: 4,
758            pyramid_levels: 2,
759            estimation: MotionEstimation::Auto,
760        };
761        m.validate().unwrap();
762    }
763
764    #[test]
765    fn resolved_estimation_is_none_when_mode_is_none() {
766        assert_eq!(MotionCompensationMode::None.resolved_estimation(4), None);
767    }
768
769    #[test]
770    fn resolved_estimation_resolves_auto_from_the_mode() {
771        let m = MotionCompensationMode::Mvtools {
772            blksize: 16,
773            overlap: 8,
774            search_radius: 4,
775            pyramid_levels: 2,
776            estimation: MotionEstimation::Auto,
777        };
778        assert_eq!(m.resolved_estimation(1), Some(MotionEstimation::Direct));
779        assert_eq!(
780            m.resolved_estimation(4),
781            Some(MotionEstimation::chained_default())
782        );
783    }
784
785    #[test]
786    fn pair_ring_slot_count_is_double_radius() {
787        assert_eq!(pair_ring_slot_count(3), 6);
788        assert_eq!(pair_ring_slot_count(1), 2);
789    }
790
791    #[test]
792    fn motion_ctx_blocks_match_step() {
793        let mode = MotionCompensationMode::Mvtools {
794            blksize: 16,
795            overlap: 8,
796            search_radius: 4,
797            pyramid_levels: 2,
798            estimation: MotionEstimation::Direct,
799        };
800        let ctx = MotionCtx::new(mode, 1920, 1080, StorageAlign::new(32)).unwrap();
801        assert_eq!(ctx.step, 8);
802        assert_eq!(ctx.blocks_x, 1920u32.div_ceil(8));
803        assert_eq!(ctx.blocks_y, 1080u32.div_ceil(8));
804    }
805}