use cubecl::prelude::*;
use cubecl::server::Handle;
use super::helpers::*;
use crate::nlmeans::kernels::motion::{nlm_mc_block_match_coarse, nlm_mc_block_match_fine};
use crate::nlmeans::motion::{
CHAINED_RADIUS_THRESHOLD,
DEFAULT_BLKSIZE,
DEFAULT_OVERLAP,
DEFAULT_PYRAMID_LEVELS,
DEFAULT_SEARCH_RADIUS,
MotionCtx,
mv_field_byte_offset,
neighbour_idx_for_k,
pair_byte_offset,
};
use crate::nlmeans::*;
fn frame_with_square(
w: u32,
h: u32,
background: f32,
square_x: u32,
square_y: u32,
square_size: u32,
square_val: f32,
) -> Vec<f32> {
let mut frame = vec![background; (w * h) as usize];
for dy in 0..square_size {
for dx in 0..square_size {
let x = square_x + dx;
let y = square_y + dy;
if x < w && y < h {
frame[(y * w + x) as usize] = square_val;
}
}
}
frame
}
#[allow(clippy::too_many_arguments)]
fn run_fine_block_match_single_block(
blksize: u32,
search_radius: u32,
centre: &[f32],
neighbour: &[f32],
sad_noise_floor: f32,
thsad: f32,
) -> (i32, i32, f32) {
let client = make_client();
let level_len = (blksize * blksize) as usize;
assert_eq!(centre.len(), level_len);
assert_eq!(neighbour.len(), level_len);
let centre_buf = client.create_from_slice(f32::as_bytes(centre));
let neighbour_buf = client.create_from_slice(f32::as_bytes(neighbour));
let mv_field = client.empty(2 * size_of::<i32>());
let confidence = client.empty(size_of::<f32>());
let grid = CubeCount::new_2d(1, 1);
let dim = CubeDim::new_2d(8, 8);
unsafe {
nlm_mc_block_match_fine::launch_unchecked::<R>(
&client,
grid,
dim,
ArrayArg::from_raw_parts(centre_buf, level_len),
ArrayArg::from_raw_parts(neighbour_buf, level_len),
ArrayArg::from_raw_parts(mv_field.clone(), 2),
ArrayArg::from_raw_parts(confidence.clone(), 1),
true,
sad_noise_floor,
thsad,
blksize,
blksize,
blksize,
blksize,
search_radius,
0u32,
1,
1,
);
}
let mv_bytes = client.read_one(mv_field).expect("mv readback failed");
let mv = i32::from_bytes(&mv_bytes);
let conf_bytes = client.read_one(confidence).expect("confidence readback failed");
let confidence = f32::from_bytes(&conf_bytes)[0];
(mv[0], mv[1], confidence)
}
fn recover_sad_from_confidence(confidence: f32, thsad: f32) -> f32 {
thsad * ((1.0 - confidence) / (1.0 + confidence)).sqrt()
}
#[test]
fn block_match_fine_exact_sad_uniform_mismatch() {
let blksize = 16u32;
let d = 0.1f32;
let centre = vec![0.25f32; (blksize * blksize) as usize];
let neighbour = vec![0.25f32 + d; (blksize * blksize) as usize];
let expected_sad = (blksize * blksize) as f32 * d;
let thsad = 3.0 * expected_sad;
let (_, _, confidence) = run_fine_block_match_single_block(blksize, 0, ¢re, &neighbour, 0.0, thsad);
let measured_sad = recover_sad_from_confidence(confidence, thsad);
assert!(
(measured_sad - expected_sad).abs() < expected_sad * 0.01,
"uniform |Δ|={d} over a {blksize}x{blksize} block should give best_sad \
= {expected_sad} (blksize²·d), measured {measured_sad} (confidence={confidence})",
);
}
fn shift_clamped(val: i32, delta: i32, limit: i32) -> i32 {
(val - delta).clamp(0, limit - 1)
}
#[test]
fn block_match_fine_argmin_finds_clean_shift() {
let w = 64u32;
let h = 64u32;
let blksize = DEFAULT_BLKSIZE;
let step = blksize;
let search_radius = DEFAULT_SEARCH_RADIUS;
let blocks_x = 3u32;
let blocks_y = 3u32;
let centre = noisy_copy(w, 0.5, 0.2, 123);
let mut neighbour = vec![0.0f32; (w * h) as usize];
for y in 0..h {
for x in 0..w {
let sx = shift_clamped(x as i32, 2, w as i32) as u32;
let sy = shift_clamped(y as i32, 1, h as i32) as u32;
neighbour[(y * w + x) as usize] = centre[(sy * w + sx) as usize];
}
}
let client = make_client();
let level_len = (w * h) as usize;
let centre_buf = client.create_from_slice(f32::as_bytes(¢re));
let neighbour_buf = client.create_from_slice(f32::as_bytes(&neighbour));
let mv_len = (blocks_x * blocks_y * 2) as usize;
let mv_field = client.empty(mv_len * size_of::<i32>());
let confidence = client.empty(size_of::<f32>());
let grid = CubeCount::new_2d(blocks_x, blocks_y);
let dim = CubeDim::new_2d(8, 8);
unsafe {
nlm_mc_block_match_fine::launch_unchecked::<R>(
&client,
grid,
dim,
ArrayArg::from_raw_parts(centre_buf, level_len),
ArrayArg::from_raw_parts(neighbour_buf, level_len),
ArrayArg::from_raw_parts(mv_field.clone(), mv_len),
ArrayArg::from_raw_parts(confidence, 1),
false,
0.0,
1.0,
w,
h,
blksize,
step,
search_radius,
0u32,
blocks_x,
blocks_y,
);
}
let bytes = client.read_one(mv_field).expect("mv readback failed");
let mv = i32::from_bytes(&bytes);
let (mid_bx, mid_by) = (1u32, 1u32);
let idx = ((mid_by * blocks_x + mid_bx) * 2) as usize;
assert_eq!(
(mv[idx], mv[idx + 1]),
(2, 1),
"a clean (+2, +1) shift of the centre content should give exactly \
MV=(2, 1) at default blksize={blksize}/search_radius={search_radius}, got ({}, {})",
mv[idx],
mv[idx + 1],
);
}
fn run_coarse_block_match_single_block(
blksize: u32,
search_radius: u32,
centre: &[f32],
neighbour: &[f32],
) -> (i32, i32) {
let client = make_client();
let level_len = (blksize * blksize) as usize;
assert_eq!(centre.len(), level_len);
assert_eq!(neighbour.len(), level_len);
let centre_buf = client.create_from_slice(f32::as_bytes(centre));
let neighbour_buf = client.create_from_slice(f32::as_bytes(neighbour));
let mv_field = client.empty(2 * size_of::<i32>());
let grid = CubeCount::new_2d(1, 1);
let dim = CubeDim::new_2d(8, 8);
unsafe {
nlm_mc_block_match_coarse::launch_unchecked::<R>(
&client,
grid,
dim,
ArrayArg::from_raw_parts(centre_buf, level_len),
ArrayArg::from_raw_parts(neighbour_buf, level_len),
ArrayArg::from_raw_parts(mv_field.clone(), 2),
blksize,
blksize,
blksize,
blksize,
search_radius,
1,
1,
1,
blksize,
);
}
let mv_bytes = client.read_one(mv_field).expect("mv readback failed");
let mv = i32::from_bytes(&mv_bytes);
(mv[0], mv[1])
}
#[test]
fn block_match_fine_flat_region_tie_resolves_to_zero_motion() {
let blksize = 16u32;
let search_radius = 4u32;
let value = 0.5f32;
let centre = vec![value; (blksize * blksize) as usize];
let neighbour = vec![value; (blksize * blksize) as usize];
let (mvx, mvy, confidence) =
run_fine_block_match_single_block(blksize, search_radius, ¢re, &neighbour, 0.0, 1.0);
assert_eq!(
(mvx, mvy),
(0, 0),
"a flat region gives an exact SAD tie at every candidate, which must \
resolve to the zero-motion seed, not the window corner \
(-{search_radius}, -{search_radius}); got ({mvx}, {mvy})",
);
assert_eq!(
confidence, 1.0,
"an exact SAD=0 match should give full confidence"
);
}
#[test]
fn block_match_coarse_flat_region_tie_resolves_to_zero_motion() {
let blksize = 16u32;
let search_radius = 4u32;
let value = 0.5f32;
let centre = vec![value; (blksize * blksize) as usize];
let neighbour = vec![value; (blksize * blksize) as usize];
let (mvx, mvy) = run_coarse_block_match_single_block(blksize, search_radius, ¢re, &neighbour);
assert_eq!(
(mvx, mvy),
(0, 0),
"a flat region gives an exact SAD tie at every candidate, which the \
coarse pass must resolve to the zero-motion candidate, not the window \
corner (-{search_radius}, -{search_radius}); got ({mvx}, {mvy})",
);
}
#[test]
fn motion_compensation_uniform_passthrough() {
let client = make_client();
let w = 32;
let h = 32;
let frame = make_uniform_frame(w, h, 1, 0.5);
let params = NlmParams {
temporal_radius: 1,
search_radius: 2,
patch_radius: 2,
strength: 1.2,
self_weight: 1.0,
channels: ChannelMode::Luma,
prefilter: PrefilterMode::None,
motion_compensation: MotionCompensationMode::Mvtools {
blksize: 8,
overlap: 4,
search_radius: 2,
pyramid_levels: 2,
estimation: MotionEstimation::Direct,
},
hq: None,
};
let mut d = NlmDenoiser::<R>::new(&client, params, w, h);
d.push_frame(&frame);
d.push_frame(&frame);
d.push_frame(&frame);
let result = d.denoise().unwrap().unwrap().to_vec();
assert_eq!(result.len(), (w * h) as usize);
for (i, &v) in result.iter().enumerate() {
assert!(v.is_finite(), "pixel {i}: non-finite output {v}");
assert!(
(v - 0.5).abs() < 1e-3,
"pixel {i}: expected 0.5 (uniform input passthrough), got {v}"
);
}
}
#[test]
fn motion_compensation_with_bilateral_finite() {
let client = make_client();
let w = 32;
let h = 32;
let frame = make_uniform_frame(w, h, 1, 0.5);
let params = NlmParams {
temporal_radius: 1,
search_radius: 2,
patch_radius: 2,
strength: 1.2,
self_weight: 1.0,
channels: ChannelMode::Luma,
prefilter: PrefilterMode::Bilateral {
sigma_s: 1.0,
sigma_r: 0.1,
},
motion_compensation: MotionCompensationMode::Mvtools {
blksize: 8,
overlap: 4,
search_radius: 2,
pyramid_levels: 2,
estimation: MotionEstimation::Direct,
},
hq: None,
};
let mut d = NlmDenoiser::<R>::new(&client, params, w, h);
d.push_frame(&frame);
d.push_frame(&frame);
d.push_frame(&frame);
let result = d.denoise().unwrap().unwrap().to_vec();
assert_eq!(result.len(), (w * h) as usize);
for (i, &v) in result.iter().enumerate() {
assert!(v.is_finite(), "pixel {i}: non-finite output {v}");
assert!((-0.01..=1.01).contains(&v), "pixel {i}: out-of-range output {v}");
}
}
#[test]
fn motion_compensation_translating_square_preserves_centre() {
let client = make_client();
let w = 32u32;
let h = 32u32;
let bg = 0.3;
let sq_val = 0.8;
let sq_size = 4u32;
let f0 = frame_with_square(w, h, bg, 12, 12, sq_size, sq_val);
let f1 = frame_with_square(w, h, bg, 14, 14, sq_size, sq_val);
let f2 = frame_with_square(w, h, bg, 16, 16, sq_size, sq_val);
let params = NlmParams {
temporal_radius: 1,
search_radius: 2,
patch_radius: 2,
strength: 1.2,
self_weight: 1.0,
channels: ChannelMode::Luma,
prefilter: PrefilterMode::None,
motion_compensation: MotionCompensationMode::Mvtools {
blksize: 8,
overlap: 4,
search_radius: 2,
pyramid_levels: 2,
estimation: MotionEstimation::Direct,
},
hq: None,
};
let mut d = NlmDenoiser::<R>::new(&client, params, w, h);
d.push_frame(&f0);
d.push_frame(&f1);
d.push_frame(&f2);
let result = d.denoise().unwrap().unwrap().to_vec();
assert_eq!(result.len(), (w * h) as usize);
for (i, &v) in result.iter().enumerate() {
assert!(v.is_finite(), "pixel {i}: non-finite output {v}");
assert!((-0.01..=1.01).contains(&v), "pixel {i}: out-of-range output {v}");
}
let halfway = (bg + sq_val) * 0.5;
let centre_val = result[(15 * w + 15) as usize];
assert!(
centre_val > halfway,
"centre of moving square should remain above halfway between bg ({bg}) \
and sq_val ({sq_val}) (= {halfway}), got {centre_val}",
);
let bg_val = result[(2 * w + 2) as usize];
assert!(
(bg_val - bg).abs() < 0.05,
"background pixel (2, 2) should stay near {bg}, got {bg_val} \
(MC may be warping neighbour squares into the background region)",
);
}
#[test]
fn motion_compensation_1080_square_odd_block_count_dispatch_succeeds() {
let client = make_client();
let w = 1080u32;
let h = 1080u32;
let frame = make_uniform_frame(w, h, 1, 0.5);
let params = NlmParams {
temporal_radius: 1,
search_radius: 2,
patch_radius: 2,
strength: 1.2,
self_weight: 1.0,
channels: ChannelMode::Luma,
prefilter: PrefilterMode::None,
motion_compensation: MotionCompensationMode::mvtools_default(),
hq: None,
};
let mc = MotionCtx::new(params.motion_compensation, w, h, test_align()).unwrap();
assert_eq!(
mc.blocks_x * mc.blocks_y,
18225,
"test premise: this geometry gives an odd block count"
);
let mut d = NlmDenoiser::<R>::new(&client, params, w, h);
d.push_frame(&frame);
d.push_frame(&frame);
d.push_frame(&frame);
let result = d.denoise().unwrap().unwrap().to_vec();
assert_eq!(result.len(), (w * h) as usize);
for (i, &v) in result.iter().enumerate() {
assert!(v.is_finite(), "pixel {i}: non-finite output {v}");
assert!(
(v - 0.5).abs() < 1e-3,
"pixel {i}: expected 0.5 (uniform input passthrough), got {v}"
);
}
}
#[test]
fn motion_compensation_1080_square_odd_block_count_chained_dispatch_succeeds() {
let client = make_client();
let w = 1080u32;
let h = 1080u32;
let radius = 2u32;
let frames: Vec<Vec<f32>> = (0..8)
.map(|i| make_frame_with_noisy_region(w, h, 1, 0.5, 200 + i * 4, 200, 8, 0.8))
.collect();
let params = NlmParams {
temporal_radius: radius,
search_radius: 2,
patch_radius: 2,
strength: 1.2,
self_weight: 1.0,
channels: ChannelMode::Luma,
prefilter: PrefilterMode::None,
motion_compensation: MotionCompensationMode::Mvtools {
blksize: DEFAULT_BLKSIZE,
overlap: DEFAULT_OVERLAP,
search_radius: DEFAULT_SEARCH_RADIUS,
pyramid_levels: DEFAULT_PYRAMID_LEVELS,
estimation: MotionEstimation::chained_default(),
},
hq: None,
};
let mc = MotionCtx::new(params.motion_compensation, w, h, test_align()).unwrap();
assert_eq!(
mc.blocks_x * mc.blocks_y,
18225,
"test premise: this geometry gives an odd block count"
);
let mut d = NlmDenoiser::<R>::new(&client, params, w, h);
assert!(
d.pair_ring_buf.is_some(),
"test premise: Chained estimation must allocate the pair ring"
);
let check = |frame: &[f32]| {
for (i, &v) in frame.iter().enumerate() {
assert!(v.is_finite(), "pixel {i}: non-finite output {v}");
assert!((-0.01..=1.01).contains(&v), "pixel {i}: out-of-range output {v}");
}
};
let mut emitted = 0usize;
for frame in &frames {
d.push_frame(frame);
if let Some(result) = d.denoise().unwrap() {
check(result);
emitted += 1;
}
}
d.flush(|frame| {
check(frame);
emitted += 1;
})
.unwrap();
assert_eq!(emitted, frames.len(), "expected one output per pushed frame");
}
#[allow(clippy::too_many_arguments)]
fn split_half_frame(
w: u32,
h: u32,
half: u32,
left: &[f32],
right: &[f32],
left_shift: i32,
right_shift: i32,
) -> Vec<f32> {
let mut frame = vec![0.0f32; (w * h) as usize];
for y in 0..h {
for x in 0..w {
let idx = (y * w + x) as usize;
if x < half {
let lx = shift_clamped(x as i32, left_shift, half as i32) as u32;
frame[idx] = left[(y * half + lx) as usize];
} else {
let rx = shift_clamped((x - half) as i32, right_shift, half as i32) as u32;
frame[idx] = right[(y * half + rx) as usize];
}
}
}
frame
}
fn direct_mv_field_for_forward_neighbour(
mode: MotionCompensationMode,
w: u32,
h: u32,
base: &[f32],
neighbour: &[f32],
) -> Vec<i32> {
let params = NlmParams {
temporal_radius: 1,
search_radius: 2,
patch_radius: 2,
strength: 1.2,
self_weight: 1.0,
channels: ChannelMode::Luma,
prefilter: PrefilterMode::None,
motion_compensation: mode,
hq: None,
};
let client = make_client();
let mut d = NlmDenoiser::<R>::new(&client, params, w, h);
d.push_frame(base);
d.push_frame(base);
d.push_frame(neighbour);
d.denoise().unwrap();
let mc = MotionCtx::new(mode, w, h, test_align()).unwrap();
let neighbour_idx = neighbour_idx_for_k(1, 1);
let mv_field = d
.mv_field_buf
.as_ref()
.expect("mv_field allocated when mc_ctx is Some");
let offset = mv_field_byte_offset(&mc, neighbour_idx);
let sliced = mv_field.clone().offset_start(offset);
let bytes = d.client.read_one(sliced).expect("mv readback failed");
i32::from_bytes(&bytes).to_vec()
}
#[test]
fn coarse_seeding_handles_equal_grids() {
let w = 128u32;
let h = 64u32;
let half = 64u32;
let mode = MotionCompensationMode::Mvtools {
blksize: 8,
overlap: 4,
search_radius: 3,
pyramid_levels: 2,
estimation: MotionEstimation::Direct,
};
let mc = MotionCtx::new(mode, w, h, test_align()).unwrap();
let coarse_scale = 1u32 << (mc.pyramid_levels - 1);
let coarse_step = (mc.step / coarse_scale).max(1);
let cw = w / coarse_scale;
let coarse_blocks_x = cw.div_ceil(coarse_step).max(1);
assert_eq!(
coarse_blocks_x, mc.blocks_x,
"test premise: this geometry must give equal coarse/fine grids"
);
let left = noisy_copy(half, 0.5, 0.2, 201);
let right = noisy_copy(half, 0.5, 0.2, 202);
let left_shift = 4i32;
let right_shift = -4i32;
let base = split_half_frame(w, h, half, &left, &right, 0, 0);
let shifted = split_half_frame(w, h, half, &left, &right, left_shift, right_shift);
let data = direct_mv_field_for_forward_neighbour(mode, w, h, &base, &shifted);
let by = 4u32;
let bx_left = 8u32;
let bx_right = 24u32;
let idx_left = ((by * mc.blocks_x + bx_left) * 2) as usize;
let idx_right = ((by * mc.blocks_x + bx_right) * 2) as usize;
assert_eq!(
(data[idx_left], data[idx_left + 1]),
(left_shift, 0),
"left-half block should recover the left half's own motion ({left_shift}, 0), got ({}, {})",
data[idx_left],
data[idx_left + 1],
);
assert_eq!(
(data[idx_right], data[idx_right + 1]),
(right_shift, 0),
"right-half block should recover the right half's own motion ({right_shift}, 0), got \
({}, {}); a wrong value here means it was seeded from the wrong (left-half) coarse block",
data[idx_right],
data[idx_right + 1],
);
}
#[test]
fn coarse_seeding_still_correct_at_half_grid() {
let w = 48u32;
let h = 16u32;
let half = 24u32;
let mode = MotionCompensationMode::Mvtools {
blksize: 4,
overlap: 3,
search_radius: 2,
pyramid_levels: 2,
estimation: MotionEstimation::Direct,
};
let mc = MotionCtx::new(mode, w, h, test_align()).unwrap();
let coarse_scale = 1u32 << (mc.pyramid_levels - 1);
let coarse_step = (mc.step / coarse_scale).max(1);
let cw = w / coarse_scale;
let ch = h / coarse_scale;
let coarse_blocks_x = cw.div_ceil(coarse_step).max(1);
let coarse_blocks_y = ch.div_ceil(coarse_step).max(1);
assert_eq!(mc.step, 1, "test premise: step must floor-clamp coarse_step to 1");
assert_eq!(
mc.blocks_x,
2 * coarse_blocks_x,
"test premise: this geometry must give a genuine 2:1 fine:coarse ratio in x"
);
assert_eq!(
mc.blocks_y,
2 * coarse_blocks_y,
"test premise: this geometry must give a genuine 2:1 fine:coarse ratio in y"
);
let left = noisy_copy(half, 0.5, 0.2, 301);
let right = noisy_copy(half, 0.5, 0.2, 302);
let by = 6u32;
let bx_left = 10u32;
let bx_right = 32u32;
let mv_at = |left_shift: i32, right_shift: i32| -> ((i32, i32), (i32, i32)) {
let base = split_half_frame(w, h, half, &left, &right, 0, 0);
let shifted = split_half_frame(w, h, half, &left, &right, left_shift, right_shift);
let data = direct_mv_field_for_forward_neighbour(mode, w, h, &base, &shifted);
let idx_left = ((by * mc.blocks_x + bx_left) * 2) as usize;
let idx_right = ((by * mc.blocks_x + bx_right) * 2) as usize;
(
(data[idx_left], data[idx_left + 1]),
(data[idx_right], data[idx_right + 1]),
)
};
let (uni_left, uni_right) = mv_at(2, 2);
assert_eq!(uni_left, (2, 0), "uniform motion: left block got {uni_left:?}");
assert_eq!(uni_right, (2, 0), "uniform motion: right block got {uni_right:?}");
let (var_left, var_right) = mv_at(2, -2);
assert_eq!(var_left, (2, 0), "varying motion: left block got {var_left:?}");
assert_eq!(
var_right,
(-2, 0),
"varying motion: right block got {var_right:?}"
);
}
#[test]
fn pyramid_level0_extracted_at_one_level() {
let w = 64u32;
let h = 64u32;
let dx = 2i32;
let dy = 1i32;
let mode = MotionCompensationMode::Mvtools {
blksize: DEFAULT_BLKSIZE,
overlap: DEFAULT_OVERLAP,
search_radius: DEFAULT_SEARCH_RADIUS,
pyramid_levels: 1,
estimation: MotionEstimation::Direct,
};
let mc = MotionCtx::new(mode, w, h, test_align()).unwrap();
let world = noisy_copy(w, 0.5, 0.2, 77);
let shifted = frame_shifted_by(&world, w, h, dx, dy);
let data = direct_mv_field_for_forward_neighbour(mode, w, h, &world, &shifted);
let bx = mc.blocks_x / 2;
let by = mc.blocks_y / 2;
let idx = ((by * mc.blocks_x + bx) * 2) as usize;
assert_eq!(
(data[idx], data[idx + 1]),
(dx, dy),
"a clean ({dx}, {dy}) shift with pyramid_levels=1 should give exactly that MV at an \
interior block once level-0 luma is actually extracted, got ({}, {})",
data[idx],
data[idx + 1],
);
}
#[test]
fn coarse_seeding_covers_ragged_last_block() {
let shift = -6i32;
let mode = MotionCompensationMode::Mvtools {
blksize: DEFAULT_BLKSIZE,
overlap: DEFAULT_OVERLAP,
search_radius: 4,
pyramid_levels: 2,
estimation: MotionEstimation::Direct,
};
let w_gap = 57u32;
let h_nice = 64u32;
let build = |w: u32, h: u32| -> (MotionCtx, Vec<i32>) {
let mc = MotionCtx::new(mode, w, h, test_align()).unwrap();
let world = make_noisy_gaussian_frame(w, h, 1, 0.5, &[0.2]);
let shifted = frame_shifted_by(&world, w, h, shift, shift);
let data = direct_mv_field_for_forward_neighbour(mode, w, h, &world, &shifted);
(mc, data)
};
let at = |mc: &MotionCtx, data: &[i32], bx: u32, by: u32| -> (i32, i32) {
let idx = ((by * mc.blocks_x + bx) * 2) as usize;
(data[idx], data[idx + 1])
};
let assert_ragged_on = |mc: &MotionCtx, w: u32, h: u32, ragged_axis_is_x: bool| {
let coarse_scale = 1u32 << (mc.pyramid_levels - 1);
let coarse_step = (mc.step / coarse_scale).max(1);
let coarse_blocks_x = (w / coarse_scale).div_ceil(coarse_step).max(1);
let coarse_blocks_y = (h / coarse_scale).div_ceil(coarse_step).max(1);
if ragged_axis_is_x {
assert_eq!(w % mc.step, 1, "test premise: width must be step*k + 1");
assert_eq!(
coarse_blocks_x,
mc.blocks_x - 1,
"test premise: ragged coarse grid, one block short in x"
);
assert_eq!(
coarse_blocks_y, mc.blocks_y,
"test premise: y axis is an ordinary equal grid here"
);
} else {
assert_eq!(h % mc.step, 1, "test premise: height must be step*k + 1");
assert_eq!(
coarse_blocks_y,
mc.blocks_y - 1,
"test premise: ragged coarse grid, one block short in y"
);
assert_eq!(
coarse_blocks_x, mc.blocks_x,
"test premise: x axis is an ordinary equal grid here"
);
}
};
let (mc_x, data_x) = build(w_gap, h_nice);
assert_ragged_on(&mc_x, w_gap, h_nice, true);
let mid_bx_x = mc_x.blocks_x / 2;
let mid_by_x = mc_x.blocks_y / 2;
assert_eq!(
at(&mc_x, &data_x, mid_bx_x, mid_by_x),
(shift, shift),
"interior control block (x-axis case) should recover ({shift}, {shift})"
);
assert_eq!(
at(&mc_x, &data_x, mc_x.blocks_x - 1, mid_by_x),
(shift, shift),
"last-column block (x-axis coverage gap) should recover ({shift}, {shift})"
);
let (mc_y, data_y) = build(h_nice, w_gap);
assert_ragged_on(&mc_y, h_nice, w_gap, false);
let mid_bx_y = mc_y.blocks_x / 2;
let mid_by_y = mc_y.blocks_y / 2;
assert_eq!(
at(&mc_y, &data_y, mid_bx_y, mid_by_y),
(shift, shift),
"interior control block (y-axis case) should recover ({shift}, {shift})"
);
assert_eq!(
at(&mc_y, &data_y, mid_bx_y, mc_y.blocks_y - 1),
(shift, shift),
"last-row block (y-axis coverage gap) should recover ({shift}, {shift})"
);
}
const CHAIN_TEST_RADIUS: u32 = 2;
const CHAIN_TEST_SIZE: u32 = 64;
fn chained_params(refine_radius: u32) -> NlmParams {
NlmParams {
temporal_radius: CHAIN_TEST_RADIUS,
search_radius: 2,
patch_radius: 2,
strength: 1.2,
self_weight: 1.0,
channels: ChannelMode::Luma,
prefilter: PrefilterMode::None,
motion_compensation: MotionCompensationMode::Mvtools {
blksize: 8,
overlap: 4,
search_radius: 2,
pyramid_levels: 2,
estimation: MotionEstimation::Chained { refine_radius },
},
hq: None,
}
}
fn frame_shifted_by(world: &[f32], w: u32, h: u32, dx: i32, dy: i32) -> Vec<f32> {
let mut frame = vec![0.0f32; (w * h) as usize];
for y in 0..h {
for x in 0..w {
let sx = shift_clamped(x as i32, dx, w as i32) as u32;
let sy = shift_clamped(y as i32, dy, h as i32) as u32;
frame[(y * w + x) as usize] = world[(sy * w + sx) as usize];
}
}
frame
}
fn frame_shifted_wrapped(world: &[f32], w: u32, h: u32, dx: i32, dy: i32) -> Vec<f32> {
let mut frame = vec![0.0f32; (w * h) as usize];
for y in 0..h {
for x in 0..w {
let sx = (x as i32 - dx).rem_euclid(w as i32) as u32;
let sy = (y as i32 - dy).rem_euclid(h as i32) as u32;
frame[(y * w + x) as usize] = world[(sy * w + sx) as usize];
}
}
frame
}
fn push_constant_velocity(client: &ComputeClient<R>, radius: u32, v: i32) -> NlmDenoiser<R> {
let w = CHAIN_TEST_SIZE;
let h = CHAIN_TEST_SIZE;
let world = noisy_copy(w, 0.5, 0.2, 99);
let mut d = NlmDenoiser::<R>::new(client, chained_params(2), w, h);
let real_pushes = 1 + 3 * radius as i32 + 2;
for n in 0..real_pushes {
let frame = frame_shifted_by(&world, w, h, n * v, n * v);
d.push_frame(&frame);
}
d
}
fn composed_centre_mv(d: &NlmDenoiser<R>, radius: u32, k: i32) -> (i32, i32) {
d.run_chain_compose(radius, k)
.expect("chain compose dispatch failed");
let mc = MotionCtx::new(d.params.motion_compensation, d.width, d.height, d.align).unwrap();
let neighbour_idx = neighbour_idx_for_k(radius, k);
let mv_field = d
.mv_field_buf
.as_ref()
.expect("mv_field allocated when mc_ctx is Some");
let offset = mv_field_byte_offset(&mc, neighbour_idx);
let sliced = mv_field.clone().offset_start(offset);
let bytes = d.client.read_one(sliced).expect("mv readback failed");
let data = i32::from_bytes(&bytes);
let bx = mc.blocks_x / 2;
let by = mc.blocks_y / 2;
let idx = ((by * mc.blocks_x + bx) * 2) as usize;
(data[idx], data[idx + 1])
}
fn assert_pair_ring_zero_from(d: &NlmDenoiser<R>, ring_head_before: i32, radius: u32) {
let mc = MotionCtx::new(d.params.motion_compensation, d.width, d.height, d.align).unwrap();
let pair_ring = d
.pair_ring_buf
.as_ref()
.expect("pair_ring allocated when Chained is active");
let pair_ring_slots = 2 * radius as i32;
let dir_len = mc.pair_direction_len() as usize;
for i in 0..radius as i32 {
let slot = (ring_head_before + i).rem_euclid(pair_ring_slots) as u32;
for direction in 0..2u32 {
let offset = pair_byte_offset(&mc, slot, direction);
let sliced = pair_ring.clone().offset_start(offset);
let bytes = d.client.read_one(sliced).expect("pair ring readback failed");
let data = i32::from_bytes(&bytes);
assert!(
data[..dir_len].iter().all(|&v| v == 0),
"duplicate pair slot {slot} direction {direction} should be zero-filled, got {:?}",
&data[..dir_len],
);
}
}
}
#[test]
fn chain_compose_zero_motion_gives_zero_mv() {
let client = make_client();
let radius = CHAIN_TEST_RADIUS;
let d = push_constant_velocity(&client, radius, 0);
for k in 1..=radius as i32 {
assert_eq!(
composed_centre_mv(&d, radius, k),
(0, 0),
"forward k={k} should compose to zero motion on a static sequence"
);
assert_eq!(
composed_centre_mv(&d, radius, -k),
(0, 0),
"backward k={k} should compose to zero motion on a static sequence"
);
}
}
#[test]
fn chain_compose_constant_velocity_matches_k_times_v() {
let client = make_client();
let radius = CHAIN_TEST_RADIUS;
let v = 2;
let d = push_constant_velocity(&client, radius, v);
for k in 1..=radius as i32 {
assert_eq!(
composed_centre_mv(&d, radius, k),
(k * v, k * v),
"forward k={k} should compose to exactly k*v = ({}, {})",
k * v,
k * v
);
}
}
#[test]
fn chain_compose_backward_direction_matches_negative_k_times_v() {
let client = make_client();
let radius = CHAIN_TEST_RADIUS;
let v = 2;
let d = push_constant_velocity(&client, radius, v);
for k in 1..=radius as i32 {
assert_eq!(
composed_centre_mv(&d, radius, -k),
(-k * v, -k * v),
"backward k={k} should compose to exactly -k*v = ({}, {})",
-k * v,
-k * v
);
}
}
#[test]
fn chain_compose_duplicated_slot_pairs_are_zero_filled() {
let client = make_client();
let radius = CHAIN_TEST_RADIUS;
let w = CHAIN_TEST_SIZE;
let h = CHAIN_TEST_SIZE;
let world = noisy_copy(w, 0.5, 0.2, 7);
let mut d = NlmDenoiser::<R>::new(&client, chained_params(2), w, h);
d.push_frame(&world);
assert_pair_ring_zero_from(&d, 1, radius);
for n in 1..=(3 * radius) {
let frame = frame_shifted_by(&world, w, h, n as i32, n as i32);
d.push_frame(&frame);
}
let ring_head_before_flush = d.ring_head as i32;
d.flush(|_| {}).expect("flush failed");
assert_pair_ring_zero_from(&d, ring_head_before_flush, radius);
}
fn chained_hq_params(radius: u32, refine_radius: u32) -> NlmParams {
NlmParams {
temporal_radius: radius,
search_radius: 2,
patch_radius: 2,
strength: 1.2,
self_weight: 1.0,
channels: ChannelMode::Luma,
prefilter: PrefilterMode::None,
motion_compensation: MotionCompensationMode::Mvtools {
blksize: 8,
overlap: 4,
search_radius: 2,
pyramid_levels: 2,
estimation: MotionEstimation::Chained { refine_radius },
},
hq: Some(HqParams {
auto_strength: true,
noise_floor: true,
sigma_override: None,
temporal_confidence: true,
thsad_scale: 1.0,
sigma_scale: 1.0,
}),
}
}
fn chained_end_to_end_finite(radius: u32) {
let client = make_client();
let w = 32u32;
let h = 32u32;
let mut denoiser = NlmDenoiser::<R>::new(&client, chained_hq_params(radius, 2), w, h);
let frames: Vec<Vec<f32>> = (0..8)
.map(|i| make_frame_with_noisy_region(w, h, 1, 0.5, 6 + i, 8, 2, 0.8))
.collect();
let mut emitted = 0usize;
let check = |frame: &[f32]| {
for (i, &v) in frame.iter().enumerate() {
assert!(v.is_finite(), "pixel {i}: non-finite output {v}");
assert!((0.0..=1.0).contains(&v), "pixel {i}: out-of-range output {v}");
}
};
for frame in &frames {
denoiser.push_frame(frame);
if let Some(result) = denoiser.denoise().unwrap() {
check(result);
emitted += 1;
}
}
denoiser
.flush(|frame| {
check(frame);
emitted += 1;
})
.unwrap();
assert_eq!(emitted, frames.len(), "expected one output per pushed frame");
}
#[test]
fn chained_end_to_end_finite_r2() {
chained_end_to_end_finite(2);
}
#[test]
fn chained_end_to_end_finite_r4() {
chained_end_to_end_finite(4);
}
#[test]
fn direct_estimation_default_and_explicit_construction_match_bit_for_bit() {
let client = make_client();
let w = 32u32;
let h = 32u32;
let frame = make_frame_with_noisy_region(w, h, 1, 0.5, 16, 16, 4, 0.8);
let run = |mc: MotionCompensationMode| {
let params = NlmParams {
temporal_radius: 1,
search_radius: 2,
patch_radius: 2,
strength: 1.2,
self_weight: 1.0,
channels: ChannelMode::Luma,
prefilter: PrefilterMode::None,
motion_compensation: mc,
hq: None,
};
let mut d = NlmDenoiser::<R>::new(&client, params, w, h);
d.push_frame(&frame);
d.push_frame(&frame);
d.push_frame(&frame);
d.denoise().unwrap().unwrap().to_vec()
};
let via_default = run(MotionCompensationMode::mvtools_default());
let via_explicit = run(MotionCompensationMode::Mvtools {
blksize: DEFAULT_BLKSIZE,
overlap: DEFAULT_OVERLAP,
search_radius: DEFAULT_SEARCH_RADIUS,
pyramid_levels: DEFAULT_PYRAMID_LEVELS,
estimation: MotionEstimation::Direct,
});
assert_eq!(
via_default, via_explicit,
"Direct estimation must give the same output regardless of which \
constructor produced the MotionCompensationMode value"
);
}
fn auto_params(temporal_radius: u32) -> NlmParams {
NlmParams {
temporal_radius,
search_radius: 2,
patch_radius: 2,
strength: 1.2,
self_weight: 1.0,
channels: ChannelMode::Luma,
prefilter: PrefilterMode::None,
motion_compensation: MotionCompensationMode::Mvtools {
blksize: 8,
overlap: 4,
search_radius: 2,
pyramid_levels: 2,
estimation: MotionEstimation::Auto,
},
hq: None,
}
}
#[test]
fn auto_estimation_at_high_radius_allocates_pair_ring() {
let client = make_client();
let params = auto_params(CHAINED_RADIUS_THRESHOLD);
let d = NlmDenoiser::<R>::new(&client, params, 32, 32);
assert!(
d.pair_ring_buf.is_some(),
"Auto at radius {CHAINED_RADIUS_THRESHOLD} (>= CHAINED_RADIUS_THRESHOLD) should \
resolve to Chained and allocate the pair ring"
);
}
#[test]
fn auto_estimation_at_low_radius_does_not_allocate_pair_ring() {
let client = make_client();
let params = auto_params(CHAINED_RADIUS_THRESHOLD - 1);
let d = NlmDenoiser::<R>::new(&client, params, 32, 32);
assert!(
d.pair_ring_buf.is_none(),
"Auto at radius {} (< CHAINED_RADIUS_THRESHOLD) should resolve to Direct \
and skip the pair ring",
CHAINED_RADIUS_THRESHOLD - 1
);
}
const K4_RADIUS: u32 = 4;
const K4_SIZE: u32 = 128;
const K4_V: i32 = 4;
fn k4_params(estimation: MotionEstimation) -> NlmParams {
NlmParams {
temporal_radius: K4_RADIUS,
search_radius: 2,
patch_radius: 2,
strength: 1.2,
self_weight: 1.0,
channels: ChannelMode::Luma,
prefilter: PrefilterMode::None,
motion_compensation: MotionCompensationMode::Mvtools {
blksize: 8,
overlap: 4,
search_radius: 4,
pyramid_levels: 2,
estimation,
},
hq: None,
}
}
fn read_frame_slot(
client: &ComputeClient<R>,
buf: &Handle,
slot: u32,
w: u32,
h: u32,
stored_ch: u32,
) -> Vec<f32> {
let frame_size = (w * h * stored_ch) as usize;
let byte_offset = (slot as u64) * (frame_size as u64) * (size_of::<f32>() as u64);
let sliced = buf.clone().offset_start(byte_offset);
let bytes = client.read_one(sliced).expect("frame readback failed");
f32::from_bytes(&bytes)[..frame_size].to_vec()
}
fn k4_compensated_residual(estimation: MotionEstimation, k: i32) -> f32 {
let client = make_client();
let w = K4_SIZE;
let h = K4_SIZE;
let world = noisy_copy(w, 0.5, 0.2, 55);
let mut d = NlmDenoiser::<R>::new(&client, k4_params(estimation), w, h);
let real_pushes = 2 * K4_RADIUS as i32 + 4;
for n in 0..real_pushes {
let frame = frame_shifted_wrapped(&world, w, h, n * K4_V, n * K4_V);
d.push_frame(&frame);
}
d.denoise().unwrap();
let radius = d.params.temporal_radius;
let stored_ch = d.params.channels.storage_count();
let centre_slot = d.phys_frame(radius as i32);
let neighbour_slot = d.phys_frame(radius as i32 + k);
let compensated = d
.compensated_input_buf
.as_ref()
.expect("compensated buf allocated when MC is active");
let centre_frame = read_frame_slot(&d.client, &d.input_buf, centre_slot, w, h, stored_ch);
let warped = read_frame_slot(&d.client, compensated, neighbour_slot, w, h, stored_ch);
let mut sum = 0.0f32;
let mut count = 0u32;
for y in 0..h {
for x in 0..w {
let idx = (y * w + x) as usize;
sum += (centre_frame[idx] - warped[idx]).abs();
count += 1;
}
}
sum / count as f32
}
#[test]
fn chained_beats_direct_at_k4_beyond_direct_window() {
let direct_residual = k4_compensated_residual(MotionEstimation::Direct, 4);
let chained_residual = k4_compensated_residual(MotionEstimation::Chained { refine_radius: 2 }, 4);
assert!(
direct_residual > 0.02,
"expected direct's k=4 match to show a real misalignment residual \
(window reach ≈12px, true motion 16px), got {direct_residual}"
);
assert!(
chained_residual < direct_residual * 0.5,
"chained's composed+refined k=4 alignment should beat direct's by a \
wide margin: chained={chained_residual}, direct={direct_residual}"
);
}
#[test]
fn direct_already_aligns_at_k1_inside_its_window() {
let direct_residual = k4_compensated_residual(MotionEstimation::Direct, 1);
assert!(
direct_residual < 0.02,
"direct should align cleanly at k=1 (motion {K4_V}px, well inside its ~12px reach), got {direct_residual}"
);
}