1mod analyse;
2mod chain;
3mod compensate;
4mod confidence;
5mod pyramid;
6
7#[cfg(all(test, any(feature = "vulkan", feature = "metal")))]
8pub(crate) use analyse::mv_field_byte_offset;
9pub(crate) use analyse::{confidence_byte_offset, run_analyse, run_seeded_refine};
10#[cfg(all(test, any(feature = "vulkan", feature = "metal")))]
11pub(crate) use chain::{neighbour_idx_for_k, pair_byte_offset};
12pub(crate) use chain::{run_pair_analyse, zero_pair_slot};
13pub(crate) use compensate::run_compensate;
14pub(crate) use confidence::{run_confidence_for_neighbour, sad_noise_floor, thsad};
15use cubecl::prelude::*;
16use cubecl::server::Handle;
17pub(crate) use pyramid::{pyramid_pixels_per_frame, run_pyramid_build};
18
19#[non_exhaustive]
26#[derive(Debug, Default, Clone, Copy, PartialEq)]
27pub enum MotionCompensationMode {
28 #[default]
29 None,
30 Mvtools {
31 blksize: u32,
34 overlap: u32,
39 search_radius: u32,
43 pyramid_levels: u32,
47 estimation: MotionEstimation,
52 },
53}
54
55#[non_exhaustive]
57#[derive(Debug, Default, Clone, Copy, PartialEq)]
58pub enum MotionEstimation {
59 #[default]
63 Auto,
64 Direct,
68 Chained {
73 refine_radius: u32,
77 },
78}
79
80pub const DEFAULT_REFINE_RADIUS: u32 = 2;
82
83pub const CHAINED_RADIUS_THRESHOLD: u32 = 3;
90
91impl MotionEstimation {
92 pub fn chained_default() -> Self {
95 Self::Chained {
96 refine_radius: DEFAULT_REFINE_RADIUS,
97 }
98 }
99
100 pub fn resolve(self, temporal_radius: u32) -> Self {
106 match self {
107 Self::Auto if temporal_radius >= CHAINED_RADIUS_THRESHOLD => Self::chained_default(),
108 Self::Auto => Self::Direct,
109 other => other,
110 }
111 }
112
113 pub(crate) fn validate(&self) -> Result<(), anyhow::Error> {
115 let Self::Chained { refine_radius } = *self else {
116 return Ok(());
117 };
118
119 if refine_radius == 0 || refine_radius > MAX_SEARCH_RADIUS {
120 anyhow::bail!(
121 "motion-estimation refine_radius={refine_radius} must be in 1..={MAX_SEARCH_RADIUS}"
122 );
123 }
124
125 Ok(())
126 }
127}
128
129pub const DEFAULT_BLKSIZE: u32 = 16;
132pub const DEFAULT_OVERLAP: u32 = 8;
134pub const DEFAULT_SEARCH_RADIUS: u32 = 4;
137pub const DEFAULT_PYRAMID_LEVELS: u32 = 2;
141
142pub const MAX_PYRAMID_LEVELS: u32 = 3;
146pub const MAX_SEARCH_RADIUS: u32 = 8;
149pub const MAX_BLKSIZE: u32 = 32;
152
153impl MotionCompensationMode {
154 pub fn mvtools_default() -> Self {
160 Self::Mvtools {
161 blksize: DEFAULT_BLKSIZE,
162 overlap: DEFAULT_OVERLAP,
163 search_radius: DEFAULT_SEARCH_RADIUS,
164 pyramid_levels: DEFAULT_PYRAMID_LEVELS,
165 estimation: MotionEstimation::Direct,
166 }
167 }
168
169 pub(crate) fn is_active(self) -> bool {
171 !matches!(self, Self::None)
172 }
173
174 pub(crate) fn resolved_estimation(&self, temporal_radius: u32) -> Option<MotionEstimation> {
181 match *self {
182 Self::Mvtools { estimation, .. } => Some(estimation.resolve(temporal_radius)),
183 Self::None => None,
184 }
185 }
186
187 pub fn validate(&self) -> Result<(), anyhow::Error> {
189 let Self::Mvtools {
190 blksize,
191 overlap,
192 search_radius,
193 pyramid_levels,
194 estimation,
195 } = *self
196 else {
197 return Ok(());
198 };
199
200 if blksize < 4 {
201 anyhow::bail!("motion-compensation blksize={blksize} is too small; minimum is 4 pixels per side");
202 }
203 if blksize > MAX_BLKSIZE {
204 anyhow::bail!(
205 "motion-compensation blksize={blksize} exceeds the supported maximum ({MAX_BLKSIZE})"
206 );
207 }
208 if blksize % 2 != 0 {
209 anyhow::bail!(
210 "motion-compensation blksize={blksize} must be even so the /2 coarse level is well-defined"
211 );
212 }
213 if overlap >= blksize {
214 anyhow::bail!(
215 "motion-compensation overlap={overlap} must be strictly less than blksize ({blksize}) so step > 0"
216 );
217 }
218 if search_radius == 0 || search_radius > MAX_SEARCH_RADIUS {
219 anyhow::bail!(
220 "motion-compensation search_radius={search_radius} must be in 1..={MAX_SEARCH_RADIUS}"
221 );
222 }
223 if pyramid_levels == 0 || pyramid_levels > MAX_PYRAMID_LEVELS {
224 anyhow::bail!(
225 "motion-compensation pyramid_levels={pyramid_levels} must be in 1..={MAX_PYRAMID_LEVELS}"
226 );
227 }
228
229 estimation.validate()?;
230
231 Ok(())
232 }
233}
234
235#[derive(Debug, Clone)]
242pub(crate) struct MotionCtx {
243 pub blksize: u32,
244 pub step: u32,
245 pub search_radius: u32,
246 pub pyramid_levels: u32,
247 pub blocks_x: u32,
248 pub blocks_y: u32,
249}
250
251impl MotionCtx {
252 pub fn new(mode: MotionCompensationMode, width: u32, height: u32) -> Option<Self> {
253 let MotionCompensationMode::Mvtools {
254 blksize,
255 overlap,
256 search_radius,
257 pyramid_levels,
258 estimation: _,
259 } = mode
260 else {
261 return None;
262 };
263
264 let step = blksize - overlap;
265 let blocks_x = width.div_ceil(step).max(1);
266 let blocks_y = height.div_ceil(step).max(1);
267
268 Some(Self {
269 blksize,
270 step,
271 search_radius,
272 pyramid_levels,
273 blocks_x,
274 blocks_y,
275 })
276 }
277
278 pub fn mv_slots_per_neighbour(&self) -> usize {
280 (self.blocks_x * self.blocks_y) as usize
281 }
282
283 pub(crate) fn mv_field_bytes_per_neighbour(&self) -> u64 {
292 let blocks = (self.blocks_x as u64) * (self.blocks_y as u64);
293 (blocks * 2 * size_of::<i32>() as u64).next_multiple_of(32)
294 }
295
296 pub(crate) fn confidence_bytes_per_neighbour(&self) -> u64 {
301 let blocks = (self.blocks_x as u64) * (self.blocks_y as u64);
302 (blocks * size_of::<f32>() as u64).next_multiple_of(32)
303 }
304
305 pub(crate) fn pair_direction_len(&self) -> u32 {
311 self.blocks_x * self.blocks_y * 2
312 }
313
314 pub(crate) fn pair_direction_bytes(&self) -> u64 {
322 (self.pair_direction_len() as u64 * size_of::<i32>() as u64).next_multiple_of(32)
323 }
324
325 pub(crate) fn pair_slot_bytes(&self) -> u64 {
328 2 * self.pair_direction_bytes()
329 }
330
331 pub(crate) fn pair_direction_stride(&self) -> u32 {
336 (self.pair_direction_bytes() / size_of::<i32>() as u64) as u32
337 }
338
339 pub(crate) fn pair_slot_stride(&self) -> u32 {
342 2 * self.pair_direction_stride()
343 }
344
345 pub(crate) fn confidence_only(width: u32, height: u32) -> Self {
352 Self::new(
353 MotionCompensationMode::Mvtools {
354 blksize: DEFAULT_BLKSIZE,
355 overlap: DEFAULT_OVERLAP,
356 search_radius: 0,
357 pyramid_levels: 1,
358 estimation: MotionEstimation::Direct,
359 },
360 width,
361 height,
362 )
363 .expect("Mvtools variant always yields Some")
364 }
365}
366
367pub(crate) fn pair_ring_slot_count(temporal_radius: u32) -> u32 {
379 2 * temporal_radius
380}
381
382#[allow(clippy::too_many_arguments)]
387pub(crate) fn build_pyramid_for_slot<R: Runtime>(
388 client: &ComputeClient<R>,
389 mc: &MotionCtx,
390 width: u32,
391 height: u32,
392 frame_count: u32,
393 slot: u32,
394 full_res: &Handle,
395 pyramid: &Handle,
396 stored_ch: u32,
397) -> Result<(), anyhow::Error> {
398 run_pyramid_build::<R>(
399 client,
400 mc,
401 width,
402 height,
403 frame_count,
404 slot,
405 full_res,
406 pyramid,
407 stored_ch,
408 )
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn none_is_inactive() {
417 let m = MotionCompensationMode::None;
418 assert!(!m.is_active());
419 m.validate().unwrap();
420 }
421
422 #[test]
423 fn mvtools_default_is_active() {
424 let m = MotionCompensationMode::mvtools_default();
425 assert!(m.is_active());
426 m.validate().unwrap();
427 }
428
429 #[test]
430 fn validate_rejects_tiny_blksize() {
431 let m = MotionCompensationMode::Mvtools {
432 blksize: 2,
433 overlap: 0,
434 search_radius: 4,
435 pyramid_levels: 2,
436 estimation: MotionEstimation::Direct,
437 };
438 assert!(m.validate().is_err());
439 }
440
441 #[test]
442 fn validate_rejects_odd_blksize() {
443 let m = MotionCompensationMode::Mvtools {
444 blksize: 9,
445 overlap: 0,
446 search_radius: 4,
447 pyramid_levels: 2,
448 estimation: MotionEstimation::Direct,
449 };
450 assert!(m.validate().is_err());
451 }
452
453 #[test]
454 fn validate_rejects_overlap_equal_to_blksize() {
455 let m = MotionCompensationMode::Mvtools {
456 blksize: 16,
457 overlap: 16,
458 search_radius: 4,
459 pyramid_levels: 2,
460 estimation: MotionEstimation::Direct,
461 };
462 assert!(m.validate().is_err());
464 }
465
466 #[test]
467 fn validate_accepts_half_overlap() {
468 let m = MotionCompensationMode::Mvtools {
469 blksize: 16,
470 overlap: 8,
471 search_radius: 4,
472 pyramid_levels: 2,
473 estimation: MotionEstimation::Direct,
474 };
475 m.validate().unwrap();
476 }
477
478 #[test]
479 fn validate_rejects_zero_search_radius() {
480 let m = MotionCompensationMode::Mvtools {
481 blksize: 16,
482 overlap: 4,
483 search_radius: 0,
484 pyramid_levels: 2,
485 estimation: MotionEstimation::Direct,
486 };
487 assert!(m.validate().is_err());
488 }
489
490 #[test]
491 fn validate_rejects_zero_pyramid_levels() {
492 let m = MotionCompensationMode::Mvtools {
493 blksize: 16,
494 overlap: 4,
495 search_radius: 4,
496 pyramid_levels: 0,
497 estimation: MotionEstimation::Direct,
498 };
499 assert!(m.validate().is_err());
500 }
501
502 #[test]
503 fn chained_default_is_valid() {
504 let m = MotionCompensationMode::Mvtools {
505 blksize: 16,
506 overlap: 8,
507 search_radius: 4,
508 pyramid_levels: 2,
509 estimation: MotionEstimation::chained_default(),
510 };
511 m.validate().unwrap();
512 assert_eq!(
513 m,
514 MotionCompensationMode::Mvtools {
515 blksize: 16,
516 overlap: 8,
517 search_radius: 4,
518 pyramid_levels: 2,
519 estimation: MotionEstimation::Chained {
520 refine_radius: DEFAULT_REFINE_RADIUS
521 },
522 }
523 );
524 }
525
526 #[test]
527 fn validate_rejects_zero_refine_radius() {
528 let m = MotionCompensationMode::Mvtools {
529 blksize: 16,
530 overlap: 8,
531 search_radius: 4,
532 pyramid_levels: 2,
533 estimation: MotionEstimation::Chained { refine_radius: 0 },
534 };
535 assert!(m.validate().is_err());
536 }
537
538 #[test]
539 fn validate_rejects_refine_radius_above_max() {
540 let m = MotionCompensationMode::Mvtools {
541 blksize: 16,
542 overlap: 8,
543 search_radius: 4,
544 pyramid_levels: 2,
545 estimation: MotionEstimation::Chained {
546 refine_radius: MAX_SEARCH_RADIUS + 1,
547 },
548 };
549 assert!(m.validate().is_err());
550 }
551
552 #[test]
553 fn validate_accepts_refine_radius_at_max() {
554 let m = MotionCompensationMode::Mvtools {
555 blksize: 16,
556 overlap: 8,
557 search_radius: 4,
558 pyramid_levels: 2,
559 estimation: MotionEstimation::Chained {
560 refine_radius: MAX_SEARCH_RADIUS,
561 },
562 };
563 m.validate().unwrap();
564 }
565
566 #[test]
567 fn motion_estimation_default_is_auto() {
568 assert_eq!(MotionEstimation::default(), MotionEstimation::Auto);
569 }
570
571 #[test]
572 fn resolve_auto_below_threshold_gives_direct() {
573 assert_eq!(MotionEstimation::Auto.resolve(1), MotionEstimation::Direct);
574 assert_eq!(MotionEstimation::Auto.resolve(2), MotionEstimation::Direct);
575 }
576
577 #[test]
578 fn resolve_auto_at_and_above_threshold_gives_chained_default() {
579 assert_eq!(
580 MotionEstimation::Auto.resolve(CHAINED_RADIUS_THRESHOLD),
581 MotionEstimation::chained_default()
582 );
583 assert_eq!(
584 MotionEstimation::Auto.resolve(8),
585 MotionEstimation::chained_default()
586 );
587 }
588
589 #[test]
590 fn resolve_leaves_explicit_direct_unchanged_at_every_radius() {
591 for radius in 1..=8u32 {
592 assert_eq!(MotionEstimation::Direct.resolve(radius), MotionEstimation::Direct);
593 }
594 }
595
596 #[test]
597 fn resolve_leaves_explicit_chained_unchanged_at_every_radius() {
598 let chained = MotionEstimation::Chained { refine_radius: 5 };
599 for radius in 1..=8u32 {
600 assert_eq!(chained.resolve(radius), chained);
601 }
602 }
603
604 #[test]
605 fn validate_accepts_auto() {
606 let m = MotionCompensationMode::Mvtools {
607 blksize: 16,
608 overlap: 8,
609 search_radius: 4,
610 pyramid_levels: 2,
611 estimation: MotionEstimation::Auto,
612 };
613 m.validate().unwrap();
614 }
615
616 #[test]
617 fn resolved_estimation_is_none_when_mode_is_none() {
618 assert_eq!(MotionCompensationMode::None.resolved_estimation(4), None);
619 }
620
621 #[test]
622 fn resolved_estimation_resolves_auto_from_the_mode() {
623 let m = MotionCompensationMode::Mvtools {
624 blksize: 16,
625 overlap: 8,
626 search_radius: 4,
627 pyramid_levels: 2,
628 estimation: MotionEstimation::Auto,
629 };
630 assert_eq!(m.resolved_estimation(1), Some(MotionEstimation::Direct));
631 assert_eq!(
632 m.resolved_estimation(4),
633 Some(MotionEstimation::chained_default())
634 );
635 }
636
637 #[test]
638 fn pair_ring_slot_count_is_double_radius() {
639 assert_eq!(pair_ring_slot_count(3), 6);
640 assert_eq!(pair_ring_slot_count(1), 2);
641 }
642
643 #[test]
644 fn motion_ctx_blocks_match_step() {
645 let mode = MotionCompensationMode::Mvtools {
646 blksize: 16,
647 overlap: 8,
648 search_radius: 4,
649 pyramid_levels: 2,
650 estimation: MotionEstimation::Direct,
651 };
652 let ctx = MotionCtx::new(mode, 1920, 1080).unwrap();
653 assert_eq!(ctx.step, 8);
654 assert_eq!(ctx.blocks_x, 1920u32.div_ceil(8));
655 assert_eq!(ctx.blocks_y, 1080u32.div_ceil(8));
656 }
657}