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#[allow(clippy::too_many_arguments)]
532pub(crate) fn build_pyramid_for_slot<R: Runtime>(
533 client: &ComputeClient<R>,
534 mc: &MotionCtx,
535 width: u32,
536 height: u32,
537 frame_count: u32,
538 slot: u32,
539 full_res: &Handle,
540 pyramid: &Handle,
541 stored_ch: u32,
542) -> Result<(), anyhow::Error> {
543 run_pyramid_build::<R>(
544 client,
545 mc,
546 width,
547 height,
548 frame_count,
549 slot,
550 full_res,
551 pyramid,
552 stored_ch,
553 )
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[test]
561 fn none_is_inactive() {
562 let m = MotionCompensationMode::None;
563 assert!(!m.is_active());
564 m.validate().unwrap();
565 }
566
567 #[test]
568 fn mvtools_default_is_active() {
569 let m = MotionCompensationMode::mvtools_default();
570 assert!(m.is_active());
571 m.validate().unwrap();
572 }
573
574 #[test]
575 fn validate_rejects_tiny_blksize() {
576 let m = MotionCompensationMode::Mvtools {
577 blksize: 2,
578 overlap: 0,
579 search_radius: 4,
580 pyramid_levels: 2,
581 estimation: MotionEstimation::Direct,
582 };
583 assert!(m.validate().is_err());
584 }
585
586 #[test]
587 fn validate_rejects_odd_blksize() {
588 let m = MotionCompensationMode::Mvtools {
589 blksize: 9,
590 overlap: 0,
591 search_radius: 4,
592 pyramid_levels: 2,
593 estimation: MotionEstimation::Direct,
594 };
595 assert!(m.validate().is_err());
596 }
597
598 #[test]
599 fn validate_rejects_overlap_equal_to_blksize() {
600 let m = MotionCompensationMode::Mvtools {
601 blksize: 16,
602 overlap: 16,
603 search_radius: 4,
604 pyramid_levels: 2,
605 estimation: MotionEstimation::Direct,
606 };
607 // An overlap equal to blksize would leave a step of 0.
608 assert!(m.validate().is_err());
609 }
610
611 #[test]
612 fn validate_accepts_half_overlap() {
613 let m = MotionCompensationMode::Mvtools {
614 blksize: 16,
615 overlap: 8,
616 search_radius: 4,
617 pyramid_levels: 2,
618 estimation: MotionEstimation::Direct,
619 };
620 m.validate().unwrap();
621 }
622
623 #[test]
624 fn validate_rejects_zero_search_radius() {
625 let m = MotionCompensationMode::Mvtools {
626 blksize: 16,
627 overlap: 4,
628 search_radius: 0,
629 pyramid_levels: 2,
630 estimation: MotionEstimation::Direct,
631 };
632 assert!(m.validate().is_err());
633 }
634
635 #[test]
636 fn validate_rejects_zero_pyramid_levels() {
637 let m = MotionCompensationMode::Mvtools {
638 blksize: 16,
639 overlap: 4,
640 search_radius: 4,
641 pyramid_levels: 0,
642 estimation: MotionEstimation::Direct,
643 };
644 assert!(m.validate().is_err());
645 }
646
647 #[test]
648 fn chained_default_is_valid() {
649 let m = MotionCompensationMode::Mvtools {
650 blksize: 16,
651 overlap: 8,
652 search_radius: 4,
653 pyramid_levels: 2,
654 estimation: MotionEstimation::chained_default(),
655 };
656 m.validate().unwrap();
657 assert_eq!(
658 m,
659 MotionCompensationMode::Mvtools {
660 blksize: 16,
661 overlap: 8,
662 search_radius: 4,
663 pyramid_levels: 2,
664 estimation: MotionEstimation::Chained {
665 refine_radius: DEFAULT_REFINE_RADIUS
666 },
667 }
668 );
669 }
670
671 #[test]
672 fn validate_rejects_zero_refine_radius() {
673 let m = MotionCompensationMode::Mvtools {
674 blksize: 16,
675 overlap: 8,
676 search_radius: 4,
677 pyramid_levels: 2,
678 estimation: MotionEstimation::Chained { refine_radius: 0 },
679 };
680 assert!(m.validate().is_err());
681 }
682
683 #[test]
684 fn validate_rejects_refine_radius_above_max() {
685 let m = MotionCompensationMode::Mvtools {
686 blksize: 16,
687 overlap: 8,
688 search_radius: 4,
689 pyramid_levels: 2,
690 estimation: MotionEstimation::Chained {
691 refine_radius: MAX_SEARCH_RADIUS + 1,
692 },
693 };
694 assert!(m.validate().is_err());
695 }
696
697 #[test]
698 fn validate_accepts_refine_radius_at_max() {
699 let m = MotionCompensationMode::Mvtools {
700 blksize: 16,
701 overlap: 8,
702 search_radius: 4,
703 pyramid_levels: 2,
704 estimation: MotionEstimation::Chained {
705 refine_radius: MAX_SEARCH_RADIUS,
706 },
707 };
708 m.validate().unwrap();
709 }
710
711 #[test]
712 fn motion_estimation_default_is_auto() {
713 assert_eq!(MotionEstimation::default(), MotionEstimation::Auto);
714 }
715
716 #[test]
717 fn resolve_auto_below_threshold_gives_direct() {
718 assert_eq!(MotionEstimation::Auto.resolve(1), MotionEstimation::Direct);
719 assert_eq!(MotionEstimation::Auto.resolve(2), MotionEstimation::Direct);
720 }
721
722 #[test]
723 fn resolve_auto_at_and_above_threshold_gives_chained_default() {
724 assert_eq!(
725 MotionEstimation::Auto.resolve(CHAINED_RADIUS_THRESHOLD),
726 MotionEstimation::chained_default()
727 );
728 assert_eq!(
729 MotionEstimation::Auto.resolve(8),
730 MotionEstimation::chained_default()
731 );
732 }
733
734 #[test]
735 fn resolve_leaves_explicit_direct_unchanged_at_every_radius() {
736 for radius in 1..=8u32 {
737 assert_eq!(MotionEstimation::Direct.resolve(radius), MotionEstimation::Direct);
738 }
739 }
740
741 #[test]
742 fn resolve_leaves_explicit_chained_unchanged_at_every_radius() {
743 let chained = MotionEstimation::Chained { refine_radius: 5 };
744 for radius in 1..=8u32 {
745 assert_eq!(chained.resolve(radius), chained);
746 }
747 }
748
749 #[test]
750 fn validate_accepts_auto() {
751 let m = MotionCompensationMode::Mvtools {
752 blksize: 16,
753 overlap: 8,
754 search_radius: 4,
755 pyramid_levels: 2,
756 estimation: MotionEstimation::Auto,
757 };
758 m.validate().unwrap();
759 }
760
761 #[test]
762 fn resolved_estimation_is_none_when_mode_is_none() {
763 assert_eq!(MotionCompensationMode::None.resolved_estimation(4), None);
764 }
765
766 #[test]
767 fn resolved_estimation_resolves_auto_from_the_mode() {
768 let m = MotionCompensationMode::Mvtools {
769 blksize: 16,
770 overlap: 8,
771 search_radius: 4,
772 pyramid_levels: 2,
773 estimation: MotionEstimation::Auto,
774 };
775 assert_eq!(m.resolved_estimation(1), Some(MotionEstimation::Direct));
776 assert_eq!(
777 m.resolved_estimation(4),
778 Some(MotionEstimation::chained_default())
779 );
780 }
781
782 #[test]
783 fn pair_ring_slot_count_is_double_radius() {
784 assert_eq!(pair_ring_slot_count(3), 6);
785 assert_eq!(pair_ring_slot_count(1), 2);
786 }
787
788 #[test]
789 fn motion_ctx_blocks_match_step() {
790 let mode = MotionCompensationMode::Mvtools {
791 blksize: 16,
792 overlap: 8,
793 search_radius: 4,
794 pyramid_levels: 2,
795 estimation: MotionEstimation::Direct,
796 };
797 let ctx = MotionCtx::new(mode, 1920, 1080, StorageAlign::new(32)).unwrap();
798 assert_eq!(ctx.step, 8);
799 assert_eq!(ctx.blocks_x, 1920u32.div_ceil(8));
800 assert_eq!(ctx.blocks_y, 1080u32.div_ceil(8));
801 }
802}