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}