use super::types::{DetectedPageNumber, PageNumberRect, Point, Rectangle};
use std::collections::HashSet;
const MIN_MATCH_COUNT: usize = 5;
const MIN_MATCH_RATIO: f64 = 1.0 / 3.0;
const MAX_SHIFT_TEST: i32 = 300;
const BBOX_MARGIN_PERCENT: f32 = 3.0;
const MIN_CONTAINMENT_RATIO: f64 = 0.70;
const TOP_SMALL_BBOX_RATIO: f64 = 0.30;
pub fn calc_overlap_center(bboxes: &[Rectangle]) -> Point {
if bboxes.is_empty() {
return Point::default();
}
if bboxes.len() == 1 {
return bboxes[0].center_point();
}
let total_pages = bboxes.len();
let expanded: Vec<Rectangle> = bboxes.iter().map(|b| b.expand(BBOX_MARGIN_PERCENT)).collect();
let mut containment_counts: Vec<(usize, usize)> = Vec::with_capacity(expanded.len());
for (i, bbox) in expanded.iter().enumerate() {
let mut count = 0;
for other in &expanded {
if other.contains_rect(bbox) || bbox.overlaps(other) {
count += 1;
}
}
containment_counts.push((i, count));
}
let min_count = (total_pages as f64 * MIN_CONTAINMENT_RATIO).ceil() as usize;
let mut high_match_indices: Vec<usize> = containment_counts
.iter()
.filter(|(_, count)| *count >= min_count)
.map(|(idx, _)| *idx)
.collect();
if high_match_indices.is_empty() {
high_match_indices = (0..expanded.len()).collect();
}
let mut area_sorted: Vec<(usize, u64)> = high_match_indices
.iter()
.map(|&idx| (idx, expanded[idx].area()))
.collect();
area_sorted.sort_by_key(|(_, area)| *area);
let take_count = ((area_sorted.len() as f64 * TOP_SMALL_BBOX_RATIO).ceil() as usize).max(1);
let smallest_indices: Vec<usize> = area_sorted.iter().take(take_count).map(|(idx, _)| *idx).collect();
let selected_bboxes: Vec<&Rectangle> = smallest_indices.iter().map(|&idx| &expanded[idx]).collect();
calc_intersection_center(&selected_bboxes)
}
fn calc_intersection_center(bboxes: &[&Rectangle]) -> Point {
if bboxes.is_empty() {
return Point::default();
}
if bboxes.len() == 1 {
return bboxes[0].center_point();
}
let mut intersection = *bboxes[0];
for bbox in bboxes.iter().skip(1) {
if let Some(new_intersection) = intersection.intersection(bbox) {
intersection = new_intersection;
} else {
return calc_average_center(bboxes);
}
}
intersection.center_point()
}
fn calc_average_center(bboxes: &[&Rectangle]) -> Point {
if bboxes.is_empty() {
return Point::default();
}
let sum_x: i64 = bboxes.iter().map(|b| b.center().0 as i64).sum();
let sum_y: i64 = bboxes.iter().map(|b| b.center().1 as i64).sum();
let count = bboxes.len() as i64;
Point::new((sum_x / count) as i32, (sum_y / count) as i32)
}
pub fn calc_group_reference_position(
positions: &[(usize, PageNumberRect)],
is_odd: bool,
) -> Point {
let filtered: Vec<Rectangle> = positions
.iter()
.filter(|(page, _)| (*page % 2 == 1) == is_odd)
.map(|(_, rect)| Rectangle::new(rect.x as i32, rect.y as i32, rect.width, rect.height))
.collect();
calc_overlap_center(&filtered)
}
#[derive(Debug, Clone)]
pub struct PageOffsetResult {
pub physical_page: usize,
pub logical_page: Option<i32>,
pub shift_x: i32,
pub shift_y: i32,
pub page_number_position: Option<PageNumberRect>,
pub is_odd: bool,
}
impl PageOffsetResult {
pub fn no_offset(physical_page: usize) -> Self {
Self {
physical_page,
logical_page: None,
shift_x: 0,
shift_y: 0,
page_number_position: None,
is_odd: physical_page % 2 == 1,
}
}
}
#[derive(Debug, Clone)]
pub struct BookOffsetAnalysis {
pub page_number_shift: i32,
pub page_offsets: Vec<PageOffsetResult>,
pub odd_avg_x: Option<i32>,
pub even_avg_x: Option<i32>,
pub odd_avg_y: Option<i32>,
pub even_avg_y: Option<i32>,
pub match_count: usize,
pub confidence: f64,
}
impl Default for BookOffsetAnalysis {
fn default() -> Self {
Self {
page_number_shift: 0,
page_offsets: Vec::new(),
odd_avg_x: None,
even_avg_x: None,
odd_avg_y: None,
even_avg_y: None,
match_count: 0,
confidence: 0.0,
}
}
}
impl BookOffsetAnalysis {
pub fn is_reliable(&self, total_pages: usize) -> bool {
self.match_count >= 5 && self.match_count * 3 >= total_pages
}
pub fn get_offset(&self, physical_page: usize) -> Option<&PageOffsetResult> {
self.page_offsets
.iter()
.find(|p| p.physical_page == physical_page)
}
}
pub struct PageOffsetAnalyzer;
impl PageOffsetAnalyzer {
pub fn analyze_offsets(
detections: &[DetectedPageNumber],
_image_height: u32,
) -> BookOffsetAnalysis {
if detections.is_empty() {
return BookOffsetAnalysis::default();
}
let (best_shift, match_count, confidence) = Self::find_best_page_number_shift(detections);
if match_count < MIN_MATCH_COUNT
|| (match_count as f64) < (detections.len() as f64 * MIN_MATCH_RATIO)
{
return BookOffsetAnalysis {
page_number_shift: 0,
page_offsets: detections
.iter()
.map(|d| PageOffsetResult::no_offset(d.page_index + 1))
.collect(),
confidence: 0.0,
match_count: 0,
..Default::default()
};
}
let mut matched_pages: Vec<(usize, PageNumberRect, bool)> = Vec::new();
for det in detections {
let physical_page = det.page_index + 1;
let expected_logical = physical_page as i32 - best_shift;
if expected_logical >= 1 && det.number == Some(expected_logical) {
matched_pages.push((physical_page, det.position, physical_page % 2 == 1));
}
}
let positions: Vec<(usize, PageNumberRect)> = matched_pages
.iter()
.map(|(page, rect, _)| (*page, *rect))
.collect();
let odd_ref = calc_group_reference_position(&positions, true);
let even_ref = calc_group_reference_position(&positions, false);
let odd_avg_x = if odd_ref.x != 0 || positions.iter().any(|(p, _)| *p % 2 == 1) {
Some(odd_ref.x)
} else {
None
};
let odd_avg_y = if odd_ref.y != 0 || positions.iter().any(|(p, _)| *p % 2 == 1) {
Some(odd_ref.y)
} else {
None
};
let even_avg_x = if even_ref.x != 0 || positions.iter().any(|(p, _)| *p % 2 == 0) {
Some(even_ref.x)
} else {
None
};
let even_avg_y = if even_ref.y != 0 || positions.iter().any(|(p, _)| *p % 2 == 0) {
Some(even_ref.y)
} else {
None
};
let (final_odd_avg_y, final_even_avg_y) = Self::align_group_y_values(odd_avg_y, even_avg_y);
let page_offsets = Self::calculate_per_page_offsets(
detections,
best_shift,
odd_avg_x,
even_avg_x,
final_odd_avg_y,
final_even_avg_y,
);
BookOffsetAnalysis {
page_number_shift: best_shift,
page_offsets,
odd_avg_x,
even_avg_x,
odd_avg_y: final_odd_avg_y,
even_avg_y: final_even_avg_y,
match_count,
confidence,
}
}
fn find_best_page_number_shift(detections: &[DetectedPageNumber]) -> (i32, usize, f64) {
let mut best_shift = 0i32;
let mut best_score = 0.0f64;
let mut best_count = 0usize;
for shift in -MAX_SHIFT_TEST..MAX_SHIFT_TEST {
let mut score = 0.0f64;
let mut count = 0usize;
for det in detections {
let physical_page = det.page_index + 1;
let expected_logical = physical_page as i32 - shift;
if expected_logical >= 1 && det.number == Some(expected_logical) {
score += det.confidence as f64;
count += 1;
}
}
if score > best_score || (score == best_score && shift.abs() < best_shift.abs()) {
best_score = score;
best_shift = shift;
best_count = count;
}
}
let max_possible_score = detections.len() as f64 * 100.0;
let confidence = if max_possible_score > 0.0 {
best_score / max_possible_score
} else {
0.0
};
(best_shift, best_count, confidence)
}
fn align_group_y_values(
odd_avg_y: Option<i32>,
even_avg_y: Option<i32>,
) -> (Option<i32>, Option<i32>) {
match (odd_avg_y, even_avg_y) {
(Some(odd_y), Some(even_y)) => {
let diff = (odd_y - even_y).abs();
if diff < 350 {
let avg = (odd_y + even_y) / 2;
(Some(avg), Some(avg))
} else {
(Some(odd_y), Some(even_y))
}
}
_ => (odd_avg_y, even_avg_y),
}
}
fn calculate_per_page_offsets(
detections: &[DetectedPageNumber],
shift: i32,
odd_avg_x: Option<i32>,
even_avg_x: Option<i32>,
odd_avg_y: Option<i32>,
even_avg_y: Option<i32>,
) -> Vec<PageOffsetResult> {
detections
.iter()
.map(|det| {
let physical_page = det.page_index + 1;
let is_odd = physical_page % 2 == 1;
let expected_logical = physical_page as i32 - shift;
let matched = expected_logical >= 1 && det.number == Some(expected_logical);
if matched {
let avg_x = if is_odd { odd_avg_x } else { even_avg_x };
let avg_y = if is_odd { odd_avg_y } else { even_avg_y };
let center_x = det.position.x as i32 + det.position.width as i32 / 2;
let center_y = det.position.y as i32 + det.position.height as i32 / 2;
let shift_x = avg_x.map(|ax| ax - center_x).unwrap_or(0);
let shift_y = avg_y.map(|ay| ay - center_y).unwrap_or(0);
PageOffsetResult {
physical_page,
logical_page: Some(expected_logical),
shift_x,
shift_y,
page_number_position: Some(det.position),
is_odd,
}
} else {
PageOffsetResult::no_offset(physical_page)
}
})
.collect()
}
pub fn interpolate_missing_offsets(analysis: &mut BookOffsetAnalysis, total_pages: usize) {
let existing: HashSet<usize> = analysis
.page_offsets
.iter()
.map(|p| p.physical_page)
.collect();
for page in 1..=total_pages {
if !existing.contains(&page) {
analysis
.page_offsets
.push(PageOffsetResult::no_offset(page));
}
}
analysis.page_offsets.sort_by_key(|p| p.physical_page);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_page_offset_result_no_offset() {
let result = PageOffsetResult::no_offset(5);
assert_eq!(result.physical_page, 5);
assert_eq!(result.logical_page, None);
assert_eq!(result.shift_x, 0);
assert_eq!(result.shift_y, 0);
assert!(result.is_odd);
}
#[test]
fn test_page_offset_result_even_page() {
let result = PageOffsetResult::no_offset(6);
assert!(!result.is_odd);
}
#[test]
fn test_book_offset_analysis_default() {
let analysis = BookOffsetAnalysis::default();
assert_eq!(analysis.page_number_shift, 0);
assert!(analysis.page_offsets.is_empty());
assert_eq!(analysis.match_count, 0);
assert_eq!(analysis.confidence, 0.0);
}
#[test]
fn test_book_offset_analysis_reliability() {
let mut analysis = BookOffsetAnalysis::default();
assert!(!analysis.is_reliable(100));
analysis.match_count = 4;
assert!(!analysis.is_reliable(100));
analysis.match_count = 5;
assert!(!analysis.is_reliable(100));
analysis.match_count = 40;
assert!(analysis.is_reliable(100)); }
#[test]
fn test_analyze_empty_detections() {
let detections: Vec<DetectedPageNumber> = vec![];
let analysis = PageOffsetAnalyzer::analyze_offsets(&detections, 7000);
assert_eq!(analysis.page_number_shift, 0);
assert!(analysis.page_offsets.is_empty());
}
#[test]
fn test_interpolate_missing_offsets() {
let mut analysis = BookOffsetAnalysis {
page_offsets: vec![
PageOffsetResult::no_offset(1),
PageOffsetResult::no_offset(3),
PageOffsetResult::no_offset(5),
],
..Default::default()
};
PageOffsetAnalyzer::interpolate_missing_offsets(&mut analysis, 5);
assert_eq!(analysis.page_offsets.len(), 5);
assert_eq!(analysis.page_offsets[0].physical_page, 1);
assert_eq!(analysis.page_offsets[1].physical_page, 2);
assert_eq!(analysis.page_offsets[2].physical_page, 3);
assert_eq!(analysis.page_offsets[3].physical_page, 4);
assert_eq!(analysis.page_offsets[4].physical_page, 5);
}
#[test]
fn test_get_offset() {
let analysis = BookOffsetAnalysis {
page_offsets: vec![
PageOffsetResult::no_offset(1),
PageOffsetResult::no_offset(2),
PageOffsetResult::no_offset(3),
],
..Default::default()
};
let offset = analysis.get_offset(2);
assert!(offset.is_some());
assert_eq!(offset.unwrap().physical_page, 2);
let missing = analysis.get_offset(99);
assert!(missing.is_none());
}
#[test]
fn test_tc_pagenum_001_sequential_page_numbers() {
use crate::page_number::types::{DetectedPageNumber, PageNumberRect};
let detections = vec![
DetectedPageNumber {
page_index: 0,
number: Some(1),
position: PageNumberRect { x: 500, y: 100, width: 50, height: 20 },
confidence: 0.9,
raw_text: "1".to_string(),
},
DetectedPageNumber {
page_index: 1,
number: Some(2),
position: PageNumberRect { x: 500, y: 100, width: 50, height: 20 },
confidence: 0.9,
raw_text: "2".to_string(),
},
DetectedPageNumber {
page_index: 2,
number: Some(3),
position: PageNumberRect { x: 500, y: 100, width: 50, height: 20 },
confidence: 0.9,
raw_text: "3".to_string(),
},
];
let analysis = PageOffsetAnalyzer::analyze_offsets(&detections, 1000);
assert_eq!(analysis.page_number_shift, 0);
assert_eq!(analysis.page_offsets.len(), 3);
}
#[test]
fn test_tc_pagenum_002_missing_page_interpolation() {
use crate::page_number::types::PageNumberRect;
let mut analysis = BookOffsetAnalysis {
page_offsets: vec![
PageOffsetResult {
physical_page: 1,
logical_page: Some(1),
shift_x: 10,
shift_y: 5,
page_number_position: Some(PageNumberRect { x: 100, y: 50, width: 30, height: 20 }),
is_odd: true,
},
PageOffsetResult {
physical_page: 3,
logical_page: Some(3),
shift_x: 10,
shift_y: 5,
page_number_position: Some(PageNumberRect { x: 100, y: 50, width: 30, height: 20 }),
is_odd: true,
},
],
page_number_shift: 0,
odd_avg_x: Some(100),
even_avg_x: Some(900),
odd_avg_y: Some(50),
even_avg_y: Some(50),
match_count: 2,
confidence: 0.8,
};
PageOffsetAnalyzer::interpolate_missing_offsets(&mut analysis, 3);
assert_eq!(analysis.page_offsets.len(), 3);
let page2 = analysis.get_offset(2);
assert!(page2.is_some());
}
#[test]
fn test_tc_pagenum_003_decorative_numbers() {
let result = PageOffsetResult {
physical_page: 5,
logical_page: Some(1), shift_x: 0,
shift_y: 0,
page_number_position: None,
is_odd: true,
};
assert_eq!(result.physical_page, 5);
assert_eq!(result.logical_page, Some(1));
}
#[test]
fn test_tc_pagenum_004_roman_numerals_skipped() {
let result = PageOffsetResult {
physical_page: 1,
logical_page: None, shift_x: 0,
shift_y: 0,
page_number_position: None,
is_odd: true,
};
assert!(result.logical_page.is_none());
let no_offset = PageOffsetResult::no_offset(2);
assert_eq!(no_offset.shift_x, 0);
assert_eq!(no_offset.shift_y, 0);
}
#[test]
fn test_tc_pagenum_005_odd_even_separate_offsets() {
use crate::page_number::types::{DetectedPageNumber, PageNumberRect};
let detections = vec![
DetectedPageNumber {
page_index: 0,
number: Some(1),
position: PageNumberRect { x: 100, y: 50, width: 50, height: 20 }, confidence: 0.9,
raw_text: "1".to_string(),
},
DetectedPageNumber {
page_index: 1,
number: Some(2),
position: PageNumberRect { x: 900, y: 50, width: 50, height: 20 }, confidence: 0.9,
raw_text: "2".to_string(),
},
DetectedPageNumber {
page_index: 2,
number: Some(3),
position: PageNumberRect { x: 105, y: 52, width: 50, height: 20 }, confidence: 0.9,
raw_text: "3".to_string(),
},
DetectedPageNumber {
page_index: 3,
number: Some(4),
position: PageNumberRect { x: 895, y: 48, width: 50, height: 20 }, confidence: 0.9,
raw_text: "4".to_string(),
},
];
let analysis = PageOffsetAnalyzer::analyze_offsets(&detections, 1000);
assert!(!analysis.page_offsets.is_empty());
}
#[test]
fn test_calc_overlap_center_empty() {
let bboxes: Vec<Rectangle> = vec![];
let center = calc_overlap_center(&bboxes);
assert_eq!(center, Point::default());
}
#[test]
fn test_calc_overlap_center_single() {
let bboxes = vec![Rectangle::new(100, 200, 50, 30)];
let center = calc_overlap_center(&bboxes);
assert_eq!(center.x, 125);
assert_eq!(center.y, 215);
}
#[test]
fn test_calc_overlap_center_identical() {
let bboxes = vec![
Rectangle::new(100, 200, 50, 30),
Rectangle::new(100, 200, 50, 30),
Rectangle::new(100, 200, 50, 30),
];
let center = calc_overlap_center(&bboxes);
assert!((center.x - 125).abs() <= 5);
assert!((center.y - 215).abs() <= 5);
}
#[test]
fn test_calc_overlap_center_overlapping() {
let bboxes = vec![
Rectangle::new(100, 100, 100, 100), Rectangle::new(110, 110, 100, 100), Rectangle::new(120, 120, 100, 100), ];
let center = calc_overlap_center(&bboxes);
assert!(center.x >= 100 && center.x <= 220);
assert!(center.y >= 100 && center.y <= 220);
}
#[test]
fn test_calc_overlap_center_scattered() {
let bboxes = vec![
Rectangle::new(0, 0, 50, 50),
Rectangle::new(100, 0, 50, 50),
Rectangle::new(200, 0, 50, 50),
];
let center = calc_overlap_center(&bboxes);
assert!(center.x >= 0 && center.x <= 250);
assert!(center.y >= 0 && center.y <= 50);
}
#[test]
fn test_calc_group_reference_odd_pages() {
let positions = vec![
(1, PageNumberRect { x: 100, y: 900, width: 50, height: 30 }),
(2, PageNumberRect { x: 850, y: 900, width: 50, height: 30 }),
(3, PageNumberRect { x: 105, y: 905, width: 50, height: 30 }),
(4, PageNumberRect { x: 845, y: 895, width: 50, height: 30 }),
(5, PageNumberRect { x: 102, y: 902, width: 50, height: 30 }),
];
let odd_center = calc_group_reference_position(&positions, true);
let even_center = calc_group_reference_position(&positions, false);
assert!(odd_center.x < 200);
assert!(even_center.x > 800);
}
#[test]
fn test_calc_intersection_center_no_overlap() {
let r1 = Rectangle::new(0, 0, 10, 10);
let r2 = Rectangle::new(100, 100, 10, 10);
let bboxes: Vec<&Rectangle> = vec![&r1, &r2];
let center = calc_intersection_center(&bboxes);
assert_eq!(center.x, 55);
assert_eq!(center.y, 55);
}
#[test]
fn test_calc_average_center() {
let r1 = Rectangle::new(0, 0, 100, 100);
let r2 = Rectangle::new(100, 0, 100, 100);
let r3 = Rectangle::new(200, 0, 100, 100);
let bboxes: Vec<&Rectangle> = vec![&r1, &r2, &r3];
let center = calc_average_center(&bboxes);
assert_eq!(center.x, 150);
assert_eq!(center.y, 50);
}
#[test]
fn test_phase2_2_spec_c_sharp_compatibility() {
let odd_bboxes = vec![
Rectangle::new(900, 1000, 60, 40),
Rectangle::new(895, 1005, 65, 38),
Rectangle::new(902, 998, 58, 42),
Rectangle::new(898, 1002, 62, 40),
Rectangle::new(901, 1001, 60, 39),
];
let center = calc_overlap_center(&odd_bboxes);
assert!(center.x >= 880 && center.x <= 950, "X={} not in expected range", center.x);
assert!(center.y >= 980 && center.y <= 1050, "Y={} not in expected range", center.y);
let expected_x = 915; let expected_y = 1020; assert!((center.x - expected_x).abs() <= 20, "X deviation too large");
assert!((center.y - expected_y).abs() <= 20, "Y deviation too large");
}
}