use blazediff_shared::Image;
use super::types::{BoundingBox, ChangeRegion, ChangeType};
const PATCH_NCC_FLOOR: f32 = 0.80;
const PATCH_NCC_RETRY: f32 = 0.45;
const PATCH_SEARCH_RADIUS: i64 = 3;
const PATCH_MAD_CEIL: f32 = 0.15;
const FLAT_PATCH_MAD_CEIL: f32 = 0.05;
const FLAT_VAR_FLOOR: f64 = 4.0;
const FLAT_RING_CONTRAST: f64 = 20.0;
pub fn detect_shifts(
regions: &mut [ChangeRegion],
img1: &Image,
img2: &Image,
_mask: &[bool],
width: u32,
) {
let candidates: Vec<usize> = regions
.iter()
.enumerate()
.filter(|(_, r)| {
matches!(
r.change_type,
ChangeType::Addition
| ChangeType::Deletion
| ChangeType::ContentChange
| ChangeType::ColorChange
| ChangeType::RenderingNoise
)
})
.map(|(i, _)| i)
.collect();
let mut scored: Vec<(f32, usize, usize)> = Vec::new();
for (ci, &a) in candidates.iter().enumerate() {
for &b in &candidates[ci + 1..] {
if !size_compatible(®ions[a].bbox, ®ions[b].bbox) {
continue;
}
let kinds = (regions[a].change_type, regions[b].change_type);
if matches!(kinds, (ChangeType::ColorChange, ChangeType::ColorChange)) {
continue;
}
let forward = patch_match_score(img1, img2, ®ions[a].bbox, ®ions[b].bbox, width);
let backward = patch_match_score(img1, img2, ®ions[b].bbox, ®ions[a].bbox, width);
if let Some(score) = match (forward, backward) {
(Some(f), Some(r)) => Some(f.max(r)),
(one, None) => one,
(None, one) => one,
} {
scored.push((score, a, b));
}
}
}
scored.sort_by(|x, y| y.0.partial_cmp(&x.0).unwrap_or(std::cmp::Ordering::Equal));
let mut matched = std::collections::HashSet::new();
for (_, a, b) in scored {
if matched.contains(&a) || matched.contains(&b) {
continue;
}
matched.insert(a);
matched.insert(b);
}
for &idx in &matched {
regions[idx].change_type = ChangeType::Shift;
}
}
fn size_compatible(a: &BoundingBox, b: &BoundingBox) -> bool {
let w_ratio = a.width as f64 / b.width.max(1) as f64;
let h_ratio = a.height as f64 / b.height.max(1) as f64;
(0.55..=1.82).contains(&w_ratio) && (0.55..=1.82).contains(&h_ratio)
}
fn patch_match_score(
img1: &Image,
img2: &Image,
from: &BoundingBox,
to: &BoundingBox,
width: u32,
) -> Option<f32> {
let w = from.width.min(to.width);
let h = from.height.min(to.height);
if w == 0 || h == 0 {
return None;
}
let fx = from.x + (from.width - w) / 2;
let fy = from.y + (from.height - h) / 2;
let tx = to.x + (to.width - w) / 2;
let ty = to.y + (to.height - h) / 2;
let height = img1.height;
let (ncc, mad, flat) = crop_ncc_mad(img1, img2, fx, fy, tx, ty, w, h, width);
if flat {
let contrast1 = ring_contrast(img1, fx, fy, w, h, width);
let contrast2 = ring_contrast(img2, tx, ty, w, h, width);
let has_content = contrast1 > FLAT_RING_CONTRAST && contrast2 > FLAT_RING_CONTRAST;
return (mad <= FLAT_PATCH_MAD_CEIL && has_content).then_some(-mad);
}
if ncc >= PATCH_NCC_FLOOR && mad <= PATCH_MAD_CEIL {
return Some(ncc - mad);
}
if ncc >= PATCH_NCC_RETRY && w >= 8 && h >= 8 {
let mut best: Option<f32> = None;
for oy in -PATCH_SEARCH_RADIUS..=PATCH_SEARCH_RADIUS {
for ox in -PATCH_SEARCH_RADIUS..=PATCH_SEARCH_RADIUS {
if ox == 0 && oy == 0 {
continue;
}
let sx = tx as i64 + ox;
let sy = ty as i64 + oy;
if sx < 0 || sy < 0 || sx + w as i64 > width as i64 || sy + h as i64 > height as i64
{
continue;
}
let (ncc, mad, flat) =
crop_ncc_mad(img1, img2, fx, fy, sx as u32, sy as u32, w, h, width);
if !flat && ncc >= PATCH_NCC_FLOOR && mad <= PATCH_MAD_CEIL {
let score = ncc - mad;
if best.is_none_or(|b| score > b) {
best = Some(score);
}
}
}
}
return best;
}
None
}
fn ring_contrast(img: &Image, x: u32, y: u32, w: u32, h: u32, width: u32) -> f64 {
let pixels = img.as_u32();
let height = img.height;
let mut crop_sum = 0.0f64;
for dy in 0..h {
for dx in 0..w {
crop_sum += luminance(pixels[((y + dy) * width + x + dx) as usize]) as f64;
}
}
let crop_mean = crop_sum / (w as f64 * h as f64);
let x0 = x.saturating_sub(1);
let y0 = y.saturating_sub(1);
let x1 = (x + w + 1).min(width);
let y1 = (y + h + 1).min(height);
let mut ring_sum = 0.0f64;
let mut ring_count = 0u32;
for ry in y0..y1 {
for rx in x0..x1 {
if rx >= x && rx < x + w && ry >= y && ry < y + h {
continue;
}
ring_sum += luminance(pixels[(ry * width + rx) as usize]) as f64;
ring_count += 1;
}
}
if ring_count == 0 {
return 0.0;
}
(crop_mean - ring_sum / ring_count as f64).abs()
}
#[allow(clippy::too_many_arguments)]
fn crop_ncc_mad(
img1: &Image,
img2: &Image,
fx: u32,
fy: u32,
tx: u32,
ty: u32,
w: u32,
h: u32,
width: u32,
) -> (f32, f32, bool) {
let pixels1 = img1.as_u32();
let pixels2 = img2.as_u32();
let n = (w as f64) * (h as f64);
let mut sum1 = 0.0f64;
let mut sum2 = 0.0f64;
for dy in 0..h {
for dx in 0..w {
let i1 = ((fy + dy) * width + fx + dx) as usize;
let i2 = ((ty + dy) * width + tx + dx) as usize;
sum1 += luminance(pixels1[i1]) as f64;
sum2 += luminance(pixels2[i2]) as f64;
}
}
let mean1 = sum1 / n;
let mean2 = sum2 / n;
let mut cov = 0.0f64;
let mut var1 = 0.0f64;
let mut var2 = 0.0f64;
let mut abs_diff = 0.0f64;
for dy in 0..h {
for dx in 0..w {
let i1 = ((fy + dy) * width + fx + dx) as usize;
let i2 = ((ty + dy) * width + tx + dx) as usize;
let l1 = luminance(pixels1[i1]) as f64;
let l2 = luminance(pixels2[i2]) as f64;
let d1 = l1 - mean1;
let d2 = l2 - mean2;
cov += d1 * d2;
var1 += d1 * d1;
var2 += d2 * d2;
abs_diff += (l1 - l2).abs();
}
}
var1 /= n;
var2 /= n;
let mad = (abs_diff / n / 255.0) as f32;
if var1 < FLAT_VAR_FLOOR || var2 < FLAT_VAR_FLOOR {
return (0.0, mad, true);
}
let ncc = (cov / n / (var1 * var2).sqrt()).clamp(-1.0, 1.0) as f32;
(ncc, mad, false)
}
#[inline(always)]
fn luminance(pixel: u32) -> f32 {
let r = (pixel & 0xFF) as f32;
let g = ((pixel >> 8) & 0xFF) as f32;
let b = ((pixel >> 16) & 0xFF) as f32;
0.299 * r + 0.587 * g + 0.114 * b
}