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}