use super::content_analysis::{ContentEvidence, BG_BLEND_THRESHOLD};
use super::types::{
BoundingBox, ChangeType, ChromaStats, ClassificationSignals, ColorDeltaStats, GradientStats,
ShapeStats,
};
const STRUCTURE_PRESERVED_NCC: f32 = 0.55;
const STRUCTURE_REPLACED_NCC: f32 = 0.05;
const STRUCTURE_ASYMMETRY_MARGIN: f32 = 0.04;
const RECOLOR_MIN_MEAN_DELTA: f32 = 0.00005;
const CHROMATIC_RECOLOR_NCC: f32 = 0.88;
const CHROMA_DELTA_RECOLOR_FLOOR: f32 = 0.131;
const CHROMA_SAME_HUE_COS: f32 = 0.235;
const CHROMA_DQ_RECOLOR_FLOOR: f32 = 0.071;
const CHROMA_DRAB_SAT: f32 = 0.072;
const CHROMA_ROUGH_CEIL: f32 = 0.75;
const LUMA_PUSH_UP: f32 = 0.268;
const LUMA_PUSH_DOWN: f32 = -0.150;
const LUMA_PUSH_EDGE_CEIL: f32 = 0.101;
pub fn classify_change_type(
content: &ContentEvidence,
color_delta: &ColorDeltaStats,
gradient: &GradientStats,
shape_stats: &ShapeStats,
chroma: &ChromaStats,
bbox: &BoundingBox,
luminance_ncc: f32,
) -> (ChangeType, ClassificationSignals) {
let bbox_area = bbox.width as u64 * bbox.height as u64;
let tiny_region = bbox_area <= 25;
let low_color_delta = color_delta.mean_delta < 0.05;
let low_edge_change = gradient.edge_score < 0.05;
let low_edge_img2 = gradient.edge_score_img2 < 0.05;
let both_low_edges = low_edge_change && low_edge_img2;
let edges_correlated = both_low_edges || gradient.edge_correlation > 0.85;
let dense_fill = shape_stats.fill_ratio > 0.6;
let sparse_fill = shape_stats.fill_ratio < 0.35;
let blends_bg1 = content.bg_distance_img1 < BG_BLEND_THRESHOLD
|| (content.bg_distance_img2 > BG_BLEND_THRESHOLD
&& content.bg_distance_img1 < content.bg_distance_img2 * 0.5);
let blends_bg2 = content.bg_distance_img2 < BG_BLEND_THRESHOLD
|| (content.bg_distance_img1 > BG_BLEND_THRESHOLD
&& content.bg_distance_img2 < content.bg_distance_img1 * 0.5);
let structure_asymmetry = gradient.edge_score_img2 - gradient.edge_score;
let structure_preserved = luminance_ncc > STRUCTURE_PRESERVED_NCC;
let structure_replaced = luminance_ncc < STRUCTURE_REPLACED_NCC;
let mut signals = ClassificationSignals {
blends_with_bg_in_img1: blends_bg1,
blends_with_bg_in_img2: blends_bg2,
low_color_delta,
low_edge_change,
dense_fill,
sparse_fill,
tiny_region,
edges_correlated,
luminance_ncc,
structure_asymmetry,
bg_distance_img1: content.bg_distance_img1 as f32,
bg_distance_img2: content.bg_distance_img2 as f32,
confidence: 0.0,
};
let strong_bg_asymmetry = (blends_bg1
&& !blends_bg2
&& content.bg_distance_img2 > content.bg_distance_img1 * 2.0)
|| (blends_bg2 && !blends_bg1 && content.bg_distance_img1 > content.bg_distance_img2 * 2.0);
if tiny_region && low_color_delta {
signals.confidence = 1.0;
return (ChangeType::RenderingNoise, signals);
}
if sparse_fill && low_color_delta && low_edge_change && !strong_bg_asymmetry {
signals.confidence = matched_ratio(&[sparse_fill, low_color_delta, low_edge_change]);
return (ChangeType::RenderingNoise, signals);
}
if blends_bg1 && !blends_bg2 {
let strong_bg_asymmetry = content.bg_distance_img2 > content.bg_distance_img1 * 2.0;
let gained_structure = structure_asymmetry > STRUCTURE_ASYMMETRY_MARGIN;
if strong_bg_asymmetry || gained_structure || !structure_preserved {
let edge_boost = low_edge_change && !low_edge_img2;
signals.confidence = if edge_boost { 1.0 } else { 0.9 };
return (ChangeType::Addition, signals);
}
}
if !blends_bg1 && blends_bg2 {
let strong_bg_asymmetry = content.bg_distance_img1 > content.bg_distance_img2 * 2.0;
let lost_structure = structure_asymmetry < -STRUCTURE_ASYMMETRY_MARGIN;
if strong_bg_asymmetry || lost_structure || !structure_preserved {
let edge_boost = !low_edge_change && low_edge_img2;
signals.confidence = if edge_boost { 1.0 } else { 0.9 };
return (ChangeType::Deletion, signals);
}
}
let highly_patchy = color_delta.delta_stddev > color_delta.mean_delta * 2.0 + 0.1;
let chromatic_recolor = luminance_ncc > CHROMATIC_RECOLOR_NCC
&& edges_correlated
&& color_delta.mean_delta > RECOLOR_MIN_MEAN_DELTA;
let delta_evidence = !low_color_delta || chromatic_recolor;
if delta_evidence && !(structure_replaced && highly_patchy) && structure_preserved {
let ncc_boost = luminance_ncc.max(0.0);
signals.confidence =
((color_delta.mean_delta * 5.0).min(1.0) * 0.5 + ncc_boost * 0.5).clamp(0.0, 1.0);
return (ChangeType::ColorChange, signals);
}
let big_chroma_move =
chroma.mean_abs_dc > CHROMA_DELTA_RECOLOR_FLOOR && chroma.chroma_rough < CHROMA_ROUGH_CEIL;
if big_chroma_move {
let same_hue_family = chroma.chroma_cos > CHROMA_SAME_HUE_COS
&& chroma.mean_abs_dq <= CHROMA_DQ_RECOLOR_FLOOR
&& chroma.sat1 > CHROMA_DRAB_SAT;
if !same_hue_family {
signals.confidence = (chroma.mean_abs_dc * 3.0).clamp(0.0, 1.0) * 0.5 + 0.25;
return (ChangeType::ColorChange, signals);
}
} else {
let lighten = chroma.mean_dy > LUMA_PUSH_UP;
let darken =
chroma.mean_dy <= LUMA_PUSH_DOWN && gradient.edge_score_img2 <= LUMA_PUSH_EDGE_CEIL;
if lighten || darken {
signals.confidence = 0.6;
return (ChangeType::ColorChange, signals);
}
}
signals.confidence = 0.5;
(ChangeType::ContentChange, signals)
}
fn matched_ratio(conditions: &[bool]) -> f32 {
let matched = conditions.iter().filter(|&&c| c).count();
matched as f32 / conditions.len() as f32
}
#[cfg(test)]
mod tests {
use super::*;
fn neutral_chroma() -> ChromaStats {
ChromaStats {
mean_abs_dy: 0.0,
mean_dy: 0.0,
mean_abs_di: 0.0,
mean_abs_dq: 0.0,
mean_abs_dc: 0.0,
chroma_cos: 1.0,
sat1: 0.0,
sat2: 0.0,
chroma_rough: 0.0,
}
}
fn default_shape_stats() -> ShapeStats {
ShapeStats {
fill_ratio: 0.5,
border_ratio: 0.3,
inner_fill_ratio: 0.5,
center_density: 0.5,
row_occupancy: 0.5,
col_occupancy: 0.5,
}
}
fn square_bbox() -> BoundingBox {
BoundingBox {
x: 0,
y: 0,
width: 100,
height: 100,
}
}
fn blends_both() -> ContentEvidence {
ContentEvidence {
bg_distance_img1: 0.01,
bg_distance_img2: 0.01,
}
}
fn no_blends() -> ContentEvidence {
ContentEvidence {
bg_distance_img1: 0.50,
bg_distance_img2: 0.50,
}
}
fn flat_gradient() -> GradientStats {
GradientStats {
edge_score: 0.01,
edge_score_img2: 0.01,
edge_correlation: 0.99,
}
}
fn correlated_edges() -> GradientStats {
GradientStats {
edge_score: 0.20,
edge_score_img2: 0.18,
edge_correlation: 0.92,
}
}
fn img1_only_edges() -> GradientStats {
GradientStats {
edge_score: 0.30,
edge_score_img2: 0.02,
edge_correlation: 0.40,
}
}
fn img2_only_edges() -> GradientStats {
GradientStats {
edge_score: 0.02,
edge_score_img2: 0.30,
edge_correlation: 0.40,
}
}
fn uncorrelated_edges() -> GradientStats {
GradientStats {
edge_score: 0.30,
edge_score_img2: 0.25,
edge_correlation: 0.40,
}
}
#[test]
fn test_rendering_noise_tiny() {
let (ct, signals) = classify_change_type(
&blends_both(),
&ColorDeltaStats {
mean_delta: 0.01,
max_delta: 0.02,
delta_stddev: 0.005,
},
&flat_gradient(),
&default_shape_stats(),
&neutral_chroma(),
&BoundingBox {
x: 50,
y: 50,
width: 1,
height: 1,
},
1.0,
);
assert_eq!(ct, ChangeType::RenderingNoise);
assert!(signals.tiny_region);
assert_eq!(signals.confidence, 1.0);
}
#[test]
fn test_rendering_noise_sparse() {
let stats = ShapeStats {
fill_ratio: 0.10,
..default_shape_stats()
};
let (ct, signals) = classify_change_type(
&blends_both(),
&ColorDeltaStats {
mean_delta: 0.02,
max_delta: 0.04,
delta_stddev: 0.01,
},
&flat_gradient(),
&stats,
&neutral_chroma(),
&square_bbox(),
1.0,
);
assert_eq!(ct, ChangeType::RenderingNoise);
assert!(signals.sparse_fill);
assert!(signals.low_color_delta);
assert!(signals.low_edge_change);
}
#[test]
fn test_addition() {
let content = ContentEvidence {
bg_distance_img1: 0.02,
bg_distance_img2: 0.40,
};
let (ct, signals) = classify_change_type(
&content,
&ColorDeltaStats {
mean_delta: 0.30,
max_delta: 0.50,
delta_stddev: 0.05,
},
&img2_only_edges(),
&default_shape_stats(),
&neutral_chroma(),
&square_bbox(),
0.0,
);
assert_eq!(ct, ChangeType::Addition);
assert!(signals.blends_with_bg_in_img1);
assert!(!signals.blends_with_bg_in_img2);
assert_eq!(signals.confidence, 1.0);
}
#[test]
fn test_deletion() {
let content = ContentEvidence {
bg_distance_img1: 0.40,
bg_distance_img2: 0.02,
};
let (ct, signals) = classify_change_type(
&content,
&ColorDeltaStats {
mean_delta: 0.30,
max_delta: 0.50,
delta_stddev: 0.05,
},
&img1_only_edges(),
&default_shape_stats(),
&neutral_chroma(),
&square_bbox(),
0.0,
);
assert_eq!(ct, ChangeType::Deletion);
assert!(!signals.blends_with_bg_in_img1);
assert!(signals.blends_with_bg_in_img2);
assert_eq!(signals.confidence, 1.0);
}
#[test]
fn test_color_change_correlated_edges() {
let (ct, signals) = classify_change_type(
&no_blends(),
&ColorDeltaStats {
mean_delta: 0.20,
max_delta: 0.30,
delta_stddev: 0.03,
},
&correlated_edges(),
&default_shape_stats(),
&neutral_chroma(),
&square_bbox(),
0.95,
);
assert_eq!(ct, ChangeType::ColorChange);
assert!(signals.edges_correlated);
assert!(signals.confidence > 0.5);
}
#[test]
fn test_color_change_flat_both() {
let (ct, _) = classify_change_type(
&no_blends(),
&ColorDeltaStats {
mean_delta: 0.20,
max_delta: 0.30,
delta_stddev: 0.03,
},
&flat_gradient(),
&default_shape_stats(),
&neutral_chroma(),
&square_bbox(),
1.0,
);
assert_eq!(ct, ChangeType::ColorChange);
}
#[test]
fn test_low_delta_preserved_structure_is_color_change() {
let stats = ShapeStats {
fill_ratio: 0.95,
..default_shape_stats()
};
let (ct, _) = classify_change_type(
&no_blends(),
&ColorDeltaStats {
mean_delta: 0.02,
max_delta: 0.04,
delta_stddev: 0.01,
},
&flat_gradient(),
&stats,
&neutral_chroma(),
&square_bbox(),
1.0,
);
assert_eq!(ct, ChangeType::ColorChange);
}
#[test]
fn test_subnoise_delta_falls_through_to_content_change() {
let stats = ShapeStats {
fill_ratio: 0.95,
..default_shape_stats()
};
let (ct, _) = classify_change_type(
&no_blends(),
&ColorDeltaStats {
mean_delta: 0.00002,
max_delta: 0.00003,
delta_stddev: 0.00001,
},
&flat_gradient(),
&stats,
&neutral_chroma(),
&square_bbox(),
1.0,
);
assert_eq!(ct, ChangeType::ContentChange);
}
#[test]
fn test_content_change_uncorrelated_edges() {
let (ct, signals) = classify_change_type(
&no_blends(),
&ColorDeltaStats {
mean_delta: 0.50,
max_delta: 0.80,
delta_stddev: 0.25,
},
&uncorrelated_edges(),
&default_shape_stats(),
&neutral_chroma(),
&square_bbox(),
0.0,
);
assert_eq!(ct, ChangeType::ContentChange);
assert!(!signals.edges_correlated);
assert_eq!(signals.confidence, 0.5);
}
#[test]
fn test_tiny_region_high_color_not_noise() {
let content = ContentEvidence {
bg_distance_img1: 0.01,
bg_distance_img2: 0.40,
};
let (ct, _) = classify_change_type(
&content,
&ColorDeltaStats {
mean_delta: 0.50,
max_delta: 0.50,
delta_stddev: 0.02,
},
&img2_only_edges(),
&default_shape_stats(),
&neutral_chroma(),
&BoundingBox {
x: 50,
y: 50,
width: 1,
height: 1,
},
0.0,
);
assert_eq!(ct, ChangeType::Addition);
}
}