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