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
19use crate::nlmeans::align::StorageAlign;
20
21#[non_exhaustive]
28#[derive(Debug, Default, Clone, Copy, PartialEq)]
29pub enum MotionCompensationMode {
30 #[default]
31 None,
32 Mvtools {
33 blksize: u32,
36 overlap: u32,
41 search_radius: u32,
45 pyramid_levels: u32,
49 estimation: MotionEstimation,
54 },
55}
56
57#[non_exhaustive]
59#[derive(Debug, Default, Clone, Copy, PartialEq)]
60pub enum MotionEstimation {
61 #[default]
65 Auto,
66 Direct,
70 Chained {
75 refine_radius: u32,
79 },
80}
81
82pub const DEFAULT_REFINE_RADIUS: u32 = 2;
84
85pub const CHAINED_RADIUS_THRESHOLD: u32 = 3;
92
93impl MotionEstimation {
94 pub fn chained_default() -> Self {
97 Self::Chained {
98 refine_radius: DEFAULT_REFINE_RADIUS,
99 }
100 }
101
102 pub fn resolve(self, temporal_radius: u32) -> Self {
108 match self {
109 Self::Auto if temporal_radius >= CHAINED_RADIUS_THRESHOLD => Self::chained_default(),
110 Self::Auto => Self::Direct,
111 other => other,
112 }
113 }
114
115 pub(crate) fn validate(&self) -> Result<(), anyhow::Error> {
117 let Self::Chained { refine_radius } = *self else {
118 return Ok(());
119 };
120
121 if refine_radius == 0 || refine_radius > MAX_SEARCH_RADIUS {
122 anyhow::bail!(
123 "motion-estimation refine_radius={refine_radius} must be in 1..={MAX_SEARCH_RADIUS}"
124 );
125 }
126
127 Ok(())
128 }
129}
130
131pub const DEFAULT_BLKSIZE: u32 = 16;
134pub const DEFAULT_OVERLAP: u32 = 8;
136pub const DEFAULT_SEARCH_RADIUS: u32 = 4;
139pub const DEFAULT_PYRAMID_LEVELS: u32 = 2;
143
144pub const MAX_PYRAMID_LEVELS: u32 = 3;
148pub const MAX_SEARCH_RADIUS: u32 = 8;
151pub const MAX_BLKSIZE: u32 = 32;
154
155impl MotionCompensationMode {
156 pub fn mvtools_default() -> Self {
162 Self::Mvtools {
163 blksize: DEFAULT_BLKSIZE,
164 overlap: DEFAULT_OVERLAP,
165 search_radius: DEFAULT_SEARCH_RADIUS,
166 pyramid_levels: DEFAULT_PYRAMID_LEVELS,
167 estimation: MotionEstimation::Direct,
168 }
169 }
170
171 pub(crate) fn is_active(self) -> bool {
173 !matches!(self, Self::None)
174 }
175
176 pub(crate) fn resolved_estimation(&self, temporal_radius: u32) -> Option<MotionEstimation> {
183 match *self {
184 Self::Mvtools { estimation, .. } => Some(estimation.resolve(temporal_radius)),
185 Self::None => None,
186 }
187 }
188
189 pub fn validate(&self) -> Result<(), anyhow::Error> {
191 let Self::Mvtools {
192 blksize,
193 overlap,
194 search_radius,
195 pyramid_levels,
196 estimation,
197 } = *self
198 else {
199 return Ok(());
200 };
201
202 if blksize < 4 {
203 anyhow::bail!("motion-compensation blksize={blksize} is too small; minimum is 4 pixels per side");
204 }
205 if blksize > MAX_BLKSIZE {
206 anyhow::bail!(
207 "motion-compensation blksize={blksize} exceeds the supported maximum ({MAX_BLKSIZE})"
208 );
209 }
210 if blksize % 2 != 0 {
211 anyhow::bail!(
212 "motion-compensation blksize={blksize} must be even so the /2 coarse level is well-defined"
213 );
214 }
215 if overlap >= blksize {
216 anyhow::bail!(
217 "motion-compensation overlap={overlap} must be strictly less than blksize ({blksize}) so step > 0"
218 );
219 }
220 if search_radius == 0 || search_radius > MAX_SEARCH_RADIUS {
221 anyhow::bail!(
222 "motion-compensation search_radius={search_radius} must be in 1..={MAX_SEARCH_RADIUS}"
223 );
224 }
225 if pyramid_levels == 0 || pyramid_levels > MAX_PYRAMID_LEVELS {
226 anyhow::bail!(
227 "motion-compensation pyramid_levels={pyramid_levels} must be in 1..={MAX_PYRAMID_LEVELS}"
228 );
229 }
230
231 estimation.validate()?;
232
233 Ok(())
234 }
235}
236
237#[derive(Debug, Clone)]
244pub(crate) struct MotionCtx {
245 pub blksize: u32,
246 pub step: u32,
247 pub search_radius: u32,
248 pub pyramid_levels: u32,
249 pub blocks_x: u32,
250 pub blocks_y: u32,
251 pub align: StorageAlign,
255}
256
257impl MotionCtx {
258 pub fn new(mode: MotionCompensationMode, width: u32, height: u32, align: StorageAlign) -> Option<Self> {
259 let MotionCompensationMode::Mvtools {
260 blksize,
261 overlap,
262 search_radius,
263 pyramid_levels,
264 estimation: _,
265 } = mode
266 else {
267 return None;
268 };
269
270 let step = blksize - overlap;
271 let blocks_x = width.div_ceil(step).max(1);
272 let blocks_y = height.div_ceil(step).max(1);
273
274 Some(Self {
275 blksize,
276 step,
277 search_radius,
278 pyramid_levels,
279 blocks_x,
280 blocks_y,
281 align,
282 })
283 }
284
285 pub fn mv_slots_per_neighbour(&self) -> usize {
287 (self.blocks_x * self.blocks_y) as usize
288 }
289
290 pub(crate) fn mv_field_bytes_per_neighbour(&self) -> u64 {
299 let blocks = (self.blocks_x as u64) * (self.blocks_y as u64);
300 self.align.pad_bytes(blocks * 2 * size_of::<i32>() as u64)
301 }
302
303 pub(crate) fn confidence_bytes_per_neighbour(&self) -> u64 {
308 let blocks = (self.blocks_x as u64) * (self.blocks_y as u64);
309 self.align.pad_bytes(blocks * size_of::<f32>() as u64)
310 }
311
312 pub(crate) fn pair_direction_len(&self) -> u32 {
318 self.blocks_x * self.blocks_y * 2
319 }
320
321 pub(crate) fn pair_direction_bytes(&self) -> u64 {
329 self.align
330 .pad_bytes(self.pair_direction_len() as u64 * size_of::<i32>() as u64)
331 }
332
333 pub(crate) fn pair_slot_bytes(&self) -> u64 {
336 2 * self.pair_direction_bytes()
337 }
338
339 pub(crate) fn pair_direction_stride(&self) -> u32 {
344 (self.pair_direction_bytes() / size_of::<i32>() as u64) as u32
345 }
346
347 pub(crate) fn pair_slot_stride(&self) -> u32 {
350 2 * self.pair_direction_stride()
351 }
352
353 pub(crate) fn confidence_only(width: u32, height: u32, align: StorageAlign) -> Self {
360 Self::new(
361 MotionCompensationMode::Mvtools {
362 blksize: DEFAULT_BLKSIZE,
363 overlap: DEFAULT_OVERLAP,
364 search_radius: 0,
365 pyramid_levels: 1,
366 estimation: MotionEstimation::Direct,
367 },
368 width,
369 height,
370 align,
371 )
372 .expect("Mvtools variant always yields Some")
373 }
374}
375
376pub(crate) fn pair_ring_slot_count(temporal_radius: u32) -> u32 {
388 2 * temporal_radius
389}
390
391#[allow(clippy::too_many_arguments)]
396pub(crate) fn build_pyramid_for_slot<R: Runtime>(
397 client: &ComputeClient<R>,
398 mc: &MotionCtx,
399 width: u32,
400 height: u32,
401 frame_count: u32,
402 slot: u32,
403 full_res: &Handle,
404 pyramid: &Handle,
405 stored_ch: u32,
406) -> Result<(), anyhow::Error> {
407 run_pyramid_build::<R>(
408 client,
409 mc,
410 width,
411 height,
412 frame_count,
413 slot,
414 full_res,
415 pyramid,
416 stored_ch,
417 )
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn none_is_inactive() {
426 let m = MotionCompensationMode::None;
427 assert!(!m.is_active());
428 m.validate().unwrap();
429 }
430
431 #[test]
432 fn mvtools_default_is_active() {
433 let m = MotionCompensationMode::mvtools_default();
434 assert!(m.is_active());
435 m.validate().unwrap();
436 }
437
438 #[test]
439 fn validate_rejects_tiny_blksize() {
440 let m = MotionCompensationMode::Mvtools {
441 blksize: 2,
442 overlap: 0,
443 search_radius: 4,
444 pyramid_levels: 2,
445 estimation: MotionEstimation::Direct,
446 };
447 assert!(m.validate().is_err());
448 }
449
450 #[test]
451 fn validate_rejects_odd_blksize() {
452 let m = MotionCompensationMode::Mvtools {
453 blksize: 9,
454 overlap: 0,
455 search_radius: 4,
456 pyramid_levels: 2,
457 estimation: MotionEstimation::Direct,
458 };
459 assert!(m.validate().is_err());
460 }
461
462 #[test]
463 fn validate_rejects_overlap_equal_to_blksize() {
464 let m = MotionCompensationMode::Mvtools {
465 blksize: 16,
466 overlap: 16,
467 search_radius: 4,
468 pyramid_levels: 2,
469 estimation: MotionEstimation::Direct,
470 };
471 assert!(m.validate().is_err());
473 }
474
475 #[test]
476 fn validate_accepts_half_overlap() {
477 let m = MotionCompensationMode::Mvtools {
478 blksize: 16,
479 overlap: 8,
480 search_radius: 4,
481 pyramid_levels: 2,
482 estimation: MotionEstimation::Direct,
483 };
484 m.validate().unwrap();
485 }
486
487 #[test]
488 fn validate_rejects_zero_search_radius() {
489 let m = MotionCompensationMode::Mvtools {
490 blksize: 16,
491 overlap: 4,
492 search_radius: 0,
493 pyramid_levels: 2,
494 estimation: MotionEstimation::Direct,
495 };
496 assert!(m.validate().is_err());
497 }
498
499 #[test]
500 fn validate_rejects_zero_pyramid_levels() {
501 let m = MotionCompensationMode::Mvtools {
502 blksize: 16,
503 overlap: 4,
504 search_radius: 4,
505 pyramid_levels: 0,
506 estimation: MotionEstimation::Direct,
507 };
508 assert!(m.validate().is_err());
509 }
510
511 #[test]
512 fn chained_default_is_valid() {
513 let m = MotionCompensationMode::Mvtools {
514 blksize: 16,
515 overlap: 8,
516 search_radius: 4,
517 pyramid_levels: 2,
518 estimation: MotionEstimation::chained_default(),
519 };
520 m.validate().unwrap();
521 assert_eq!(
522 m,
523 MotionCompensationMode::Mvtools {
524 blksize: 16,
525 overlap: 8,
526 search_radius: 4,
527 pyramid_levels: 2,
528 estimation: MotionEstimation::Chained {
529 refine_radius: DEFAULT_REFINE_RADIUS
530 },
531 }
532 );
533 }
534
535 #[test]
536 fn validate_rejects_zero_refine_radius() {
537 let m = MotionCompensationMode::Mvtools {
538 blksize: 16,
539 overlap: 8,
540 search_radius: 4,
541 pyramid_levels: 2,
542 estimation: MotionEstimation::Chained { refine_radius: 0 },
543 };
544 assert!(m.validate().is_err());
545 }
546
547 #[test]
548 fn validate_rejects_refine_radius_above_max() {
549 let m = MotionCompensationMode::Mvtools {
550 blksize: 16,
551 overlap: 8,
552 search_radius: 4,
553 pyramid_levels: 2,
554 estimation: MotionEstimation::Chained {
555 refine_radius: MAX_SEARCH_RADIUS + 1,
556 },
557 };
558 assert!(m.validate().is_err());
559 }
560
561 #[test]
562 fn validate_accepts_refine_radius_at_max() {
563 let m = MotionCompensationMode::Mvtools {
564 blksize: 16,
565 overlap: 8,
566 search_radius: 4,
567 pyramid_levels: 2,
568 estimation: MotionEstimation::Chained {
569 refine_radius: MAX_SEARCH_RADIUS,
570 },
571 };
572 m.validate().unwrap();
573 }
574
575 #[test]
576 fn motion_estimation_default_is_auto() {
577 assert_eq!(MotionEstimation::default(), MotionEstimation::Auto);
578 }
579
580 #[test]
581 fn resolve_auto_below_threshold_gives_direct() {
582 assert_eq!(MotionEstimation::Auto.resolve(1), MotionEstimation::Direct);
583 assert_eq!(MotionEstimation::Auto.resolve(2), MotionEstimation::Direct);
584 }
585
586 #[test]
587 fn resolve_auto_at_and_above_threshold_gives_chained_default() {
588 assert_eq!(
589 MotionEstimation::Auto.resolve(CHAINED_RADIUS_THRESHOLD),
590 MotionEstimation::chained_default()
591 );
592 assert_eq!(
593 MotionEstimation::Auto.resolve(8),
594 MotionEstimation::chained_default()
595 );
596 }
597
598 #[test]
599 fn resolve_leaves_explicit_direct_unchanged_at_every_radius() {
600 for radius in 1..=8u32 {
601 assert_eq!(MotionEstimation::Direct.resolve(radius), MotionEstimation::Direct);
602 }
603 }
604
605 #[test]
606 fn resolve_leaves_explicit_chained_unchanged_at_every_radius() {
607 let chained = MotionEstimation::Chained { refine_radius: 5 };
608 for radius in 1..=8u32 {
609 assert_eq!(chained.resolve(radius), chained);
610 }
611 }
612
613 #[test]
614 fn validate_accepts_auto() {
615 let m = MotionCompensationMode::Mvtools {
616 blksize: 16,
617 overlap: 8,
618 search_radius: 4,
619 pyramid_levels: 2,
620 estimation: MotionEstimation::Auto,
621 };
622 m.validate().unwrap();
623 }
624
625 #[test]
626 fn resolved_estimation_is_none_when_mode_is_none() {
627 assert_eq!(MotionCompensationMode::None.resolved_estimation(4), None);
628 }
629
630 #[test]
631 fn resolved_estimation_resolves_auto_from_the_mode() {
632 let m = MotionCompensationMode::Mvtools {
633 blksize: 16,
634 overlap: 8,
635 search_radius: 4,
636 pyramid_levels: 2,
637 estimation: MotionEstimation::Auto,
638 };
639 assert_eq!(m.resolved_estimation(1), Some(MotionEstimation::Direct));
640 assert_eq!(
641 m.resolved_estimation(4),
642 Some(MotionEstimation::chained_default())
643 );
644 }
645
646 #[test]
647 fn pair_ring_slot_count_is_double_radius() {
648 assert_eq!(pair_ring_slot_count(3), 6);
649 assert_eq!(pair_ring_slot_count(1), 2);
650 }
651
652 #[test]
653 fn motion_ctx_blocks_match_step() {
654 let mode = MotionCompensationMode::Mvtools {
655 blksize: 16,
656 overlap: 8,
657 search_radius: 4,
658 pyramid_levels: 2,
659 estimation: MotionEstimation::Direct,
660 };
661 let ctx = MotionCtx::new(mode, 1920, 1080, StorageAlign::new(32)).unwrap();
662 assert_eq!(ctx.step, 8);
663 assert_eq!(ctx.blocks_x, 1920u32.div_ceil(8));
664 assert_eq!(ctx.blocks_y, 1080u32.div_ceil(8));
665 }
666}