use std::cmp::Ordering;
use crate::{
Quad,
config::RecImage,
error::{RapidOcrError, Result},
};
#[derive(Debug, Clone, PartialEq)]
pub struct PageLine {
pub polygon: Quad,
pub text: String,
pub score: f32,
}
impl PageLine {
pub fn bounds(&self) -> Bounds {
Bounds::of_polygon(&self.polygon)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bounds {
pub left: f32,
pub top: f32,
pub right: f32,
pub bottom: f32,
}
impl Bounds {
pub fn of_polygon(polygon: &Quad) -> Self {
let mut bounds = Self {
left: f32::INFINITY,
top: f32::INFINITY,
right: f32::NEG_INFINITY,
bottom: f32::NEG_INFINITY,
};
for point in polygon {
bounds.left = bounds.left.min(point[0]);
bounds.top = bounds.top.min(point[1]);
bounds.right = bounds.right.max(point[0]);
bounds.bottom = bounds.bottom.max(point[1]);
}
bounds
}
pub fn width(&self) -> f32 {
self.right - self.left
}
pub fn height(&self) -> f32 {
self.bottom - self.top
}
pub fn center_y(&self) -> f32 {
(self.top + self.bottom) / 2.0
}
pub fn union(&self, other: &Bounds) -> Bounds {
Bounds {
left: self.left.min(other.left),
top: self.top.min(other.top),
right: self.right.max(other.right),
bottom: self.bottom.max(other.bottom),
}
}
pub fn expand(&self, margin: f32) -> Bounds {
Bounds {
left: self.left - margin,
top: self.top - margin,
right: self.right + margin,
bottom: self.bottom + margin,
}
}
pub fn horizontal_overlap(&self, other: &Bounds) -> f32 {
(self.right.min(other.right) - self.left.max(other.left)).max(0.0)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LineRow {
pub members: Vec<usize>,
pub bounds: Bounds,
}
impl LineRow {
pub fn column_count(&self) -> usize {
self.members.len()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TableDetectorConfig {
pub min_rows: usize,
pub min_columns: usize,
pub row_center_tolerance: f32,
pub column_overlap_ratio: f32,
pub max_row_gap: f32,
pub region_margin_ratio: f32,
}
impl Default for TableDetectorConfig {
fn default() -> Self {
Self {
min_rows: 2,
min_columns: 2,
row_center_tolerance: 0.5,
column_overlap_ratio: 0.5,
max_row_gap: 1.5,
region_margin_ratio: 0.02,
}
}
}
impl TableDetectorConfig {
pub fn validate(&self) -> Result<()> {
if self.min_rows == 0 {
return Err(RapidOcrError::Config(
"table detection: min_rows must be at least 1".to_string(),
));
}
if self.min_columns < 2 {
return Err(RapidOcrError::Config(format!(
"table detection: min_columns must be at least 2, got {}; with fewer columns a \
vertical list of lines is indistinguishable from a table",
self.min_columns
)));
}
for (name, value) in [
("row_center_tolerance", self.row_center_tolerance),
("max_row_gap", self.max_row_gap),
("region_margin_ratio", self.region_margin_ratio),
] {
if !(value.is_finite() && value >= 0.0) {
return Err(RapidOcrError::Config(format!(
"table detection: {name} must be a finite non-negative number, got {value}"
)));
}
}
if !(self.column_overlap_ratio.is_finite()
&& self.column_overlap_ratio > 0.0
&& self.column_overlap_ratio <= 1.0)
{
return Err(RapidOcrError::Config(format!(
"table detection: column_overlap_ratio must be in (0, 1], got {}",
self.column_overlap_ratio
)));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DetectedRegion {
pub bounds: Bounds,
pub score: f32,
pub rows: usize,
pub columns: usize,
pub members: Vec<usize>,
}
#[derive(Debug, Clone)]
pub struct TableRegion {
pub polygon: Quad,
pub score: f32,
pub crop: RecImage,
pub origin: [f32; 2],
}
pub trait TableDetector {
fn detect(&mut self, image: &RecImage, page_lines: &[PageLine]) -> Result<Vec<TableRegion>>;
}
#[derive(Debug, Clone, Default)]
pub struct TextDistributionDetector {
config: TableDetectorConfig,
}
impl TextDistributionDetector {
pub fn new(config: TableDetectorConfig) -> Result<Self> {
config.validate()?;
Ok(Self { config })
}
}
impl TableDetector for TextDistributionDetector {
fn detect(&mut self, image: &RecImage, page_lines: &[PageLine]) -> Result<Vec<TableRegion>> {
let margin_for =
|bounds: &Bounds| self.config.region_margin_ratio * bounds.width().max(bounds.height());
let mut regions = Vec::new();
for region in detect_regions(page_lines, &self.config) {
let expanded = region.bounds.expand(margin_for(®ion.bounds));
let (left, top, right, bottom) =
clamped_rect(&expanded, image.width(), image.height())?;
let crop = crop_rect(image, left, top, right, bottom)?;
regions.push(TableRegion {
polygon: [
[left as f32, top as f32],
[right as f32, top as f32],
[right as f32, bottom as f32],
[left as f32, bottom as f32],
],
score: region.score,
crop,
origin: [left as f32, top as f32],
});
}
Ok(regions)
}
}
pub fn group_rows(lines: &[PageLine], config: &TableDetectorConfig) -> Vec<LineRow> {
if lines.is_empty() {
return Vec::new();
}
let bounds: Vec<Bounds> = lines.iter().map(PageLine::bounds).collect();
let heights: Vec<f32> = bounds.iter().map(Bounds::height).collect();
let tolerance = config.row_center_tolerance * median_of(&heights);
let mut order: Vec<usize> = (0..lines.len()).collect();
order.sort_by(|a, b| {
bounds[*a]
.center_y()
.partial_cmp(&bounds[*b].center_y())
.unwrap_or(Ordering::Equal)
.then_with(|| a.cmp(b))
});
let mut grouped: Vec<Vec<usize>> = Vec::new();
let mut current: Vec<usize> = vec![order[0]];
let mut center = bounds[order[0]].center_y();
for &index in &order[1..] {
let next_center = bounds[index].center_y();
if (next_center - center).abs() <= tolerance {
current.push(index);
center = current
.iter()
.map(|member| bounds[*member].center_y())
.sum::<f32>()
/ current.len() as f32;
} else {
grouped.push(std::mem::take(&mut current));
current = vec![index];
center = next_center;
}
}
grouped.push(current);
grouped
.into_iter()
.map(|mut members| {
members.sort_by(|a, b| {
bounds[*a]
.left
.partial_cmp(&bounds[*b].left)
.unwrap_or(Ordering::Equal)
.then_with(|| a.cmp(b))
});
let mut row_bounds = bounds[members[0]];
for &member in &members[1..] {
row_bounds = row_bounds.union(&bounds[member]);
}
LineRow {
members,
bounds: row_bounds,
}
})
.collect()
}
pub fn shared_column_count(
a: &LineRow,
b: &LineRow,
lines: &[PageLine],
config: &TableDetectorConfig,
) -> usize {
let mut used = vec![false; b.members.len()];
let mut matched = 0;
for &left_index in &a.members {
let left = lines[left_index].bounds();
let mut best: Option<(usize, f32)> = None;
for (slot, &right_index) in b.members.iter().enumerate() {
if used[slot] {
continue;
}
let right = lines[right_index].bounds();
let smaller = left.width().min(right.width()).max(f32::EPSILON);
let ratio = left.horizontal_overlap(&right) / smaller;
if ratio >= config.column_overlap_ratio
&& best.is_none_or(|(_, current)| ratio > current)
{
best = Some((slot, ratio));
}
}
if let Some((slot, _)) = best {
used[slot] = true;
matched += 1;
}
}
matched
}
pub fn detect_regions(lines: &[PageLine], config: &TableDetectorConfig) -> Vec<DetectedRegion> {
let rows = group_rows(lines, config);
if rows.len() < config.min_rows {
return Vec::new();
}
let row_heights: Vec<f32> = rows.iter().map(|row| row.bounds.height()).collect();
let max_gap = config.max_row_gap * median_of(&row_heights).max(f32::EPSILON);
let is_anchor = |row: &LineRow| row.column_count() >= config.min_columns;
let mut regions = Vec::new();
let mut start = 0;
while start < rows.len() {
if !is_anchor(&rows[start]) {
start += 1;
continue;
}
let mut end = start;
let mut anchor = start;
let mut anchored = 1;
let mut matched = 0;
let mut possible = 0;
while end + 1 < rows.len() {
let next = &rows[end + 1];
let gap = next.bounds.top - rows[end].bounds.bottom;
if gap > max_gap {
break;
}
if !is_anchor(next) {
end += 1;
continue;
}
let shared = shared_column_count(&rows[anchor], next, lines, config);
if shared < config.min_columns {
break;
}
matched += shared;
possible += rows[anchor].column_count().min(next.column_count());
end += 1;
anchor = end;
anchored += 1;
}
if anchored >= config.min_rows {
let members = &rows[start..=end];
let mut bounds = members[0].bounds;
let mut columns = 0;
let mut flat = Vec::new();
for row in members {
bounds = bounds.union(&row.bounds);
columns = columns.max(row.column_count());
flat.extend_from_slice(&row.members);
}
let alignment = if possible == 0 {
0.0
} else {
matched as f32 / possible as f32
};
regions.push(DetectedRegion {
bounds,
score: alignment
* row_height_consistency(members.iter().filter(|row| is_anchor(row))),
rows: anchored,
columns,
members: flat,
});
}
start = end + 1;
}
regions
}
fn row_height_consistency<'a>(rows: impl Iterator<Item = &'a LineRow>) -> f32 {
let heights: Vec<f32> = rows.map(|row| row.bounds.height()).collect();
if heights.is_empty() {
return 0.0;
}
let mean = heights.iter().sum::<f32>() / heights.len() as f32;
if !mean.is_finite() || mean <= 0.0 {
return 0.0;
}
let variance =
heights.iter().map(|h| (h - mean) * (h - mean)).sum::<f32>() / heights.len() as f32;
1.0 / (1.0 + variance.sqrt() / mean)
}
pub fn clamped_rect(
bounds: &Bounds,
width: usize,
height: usize,
) -> Result<(usize, usize, usize, usize)> {
let max_x = width as f32;
let max_y = height as f32;
let left = bounds.left.clamp(0.0, max_x).floor() as usize;
let top = bounds.top.clamp(0.0, max_y).floor() as usize;
let right = bounds.right.clamp(0.0, max_x).ceil() as usize;
let bottom = bounds.bottom.clamp(0.0, max_y).ceil() as usize;
if right <= left || bottom <= top {
return Err(RapidOcrError::InvalidImage(format!(
"table region {:?} does not intersect the {width}x{height} image",
bounds
)));
}
Ok((left, top, right, bottom))
}
pub fn crop_rect(
image: &RecImage,
left: usize,
top: usize,
right: usize,
bottom: usize,
) -> Result<RecImage> {
let width = image.width();
let height = image.height();
if right > width || bottom > height || right <= left || bottom <= top {
return Err(RapidOcrError::InvalidImage(format!(
"crop rect ({left}, {top})-({right}, {bottom}) is not inside the {width}x{height} image"
)));
}
let crop_width = right - left;
let crop_height = bottom - top;
let row_bytes = crop_width * 3;
let source = image.as_bytes();
let mut data = vec![0_u8; row_bytes * crop_height];
for y in top..bottom {
let source_start = (y * width + left) * 3;
let target_start = (y - top) * row_bytes;
data[target_start..target_start + row_bytes]
.copy_from_slice(&source[source_start..source_start + row_bytes]);
}
RecImage::new(crop_width, crop_height, data, image.color_order())
}
fn median_of(values: &[f32]) -> f32 {
let mut sorted: Vec<f32> = values.iter().copied().filter(|v| v.is_finite()).collect();
if sorted.is_empty() {
return 0.0;
}
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let middle = sorted.len() / 2;
if sorted.len().is_multiple_of(2) {
(sorted[middle - 1] + sorted[middle]) / 2.0
} else {
sorted[middle]
}
}
#[cfg(test)]
mod tests {
use super::{
Bounds, PageLine, TableDetector, TableDetectorConfig, TextDistributionDetector,
clamped_rect, detect_regions, group_rows, shared_column_count,
};
use crate::config::RecImage;
fn line(left: f32, top: f32, right: f32, bottom: f32) -> PageLine {
PageLine {
polygon: [[left, top], [right, top], [right, bottom], [left, bottom]],
text: "cell".to_string(),
score: 0.9,
}
}
fn grid(
rows: usize,
cols: usize,
cell_w: f32,
cell_h: f32,
gap_x: f32,
gap_y: f32,
) -> Vec<PageLine> {
let mut lines = Vec::new();
for row in 0..rows {
for col in 0..cols {
let left = col as f32 * (cell_w + gap_x);
let top = row as f32 * (cell_h + gap_y);
lines.push(line(left, top, left + cell_w, top + cell_h));
}
}
lines
}
fn canvas(width: usize, height: usize) -> RecImage {
let mut data = vec![0_u8; width * height * 3];
for y in 0..height {
for x in 0..width {
let index = (y * width + x) * 3;
data[index] = (x % 256) as u8;
data[index + 1] = (y % 256) as u8;
data[index + 2] = ((x + y) % 256) as u8;
}
}
RecImage::from_bgr_u8(width, height, data).expect("canvas must be constructible")
}
#[test]
fn a_three_by_three_grid_is_one_region_with_a_perfect_score() {
let lines = grid(3, 3, 100.0, 20.0, 10.0, 5.0);
let regions = detect_regions(&lines, &TableDetectorConfig::default());
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].rows, 3);
assert_eq!(regions[0].columns, 3);
assert_eq!(regions[0].members.len(), 9);
assert!(
(regions[0].score - 1.0).abs() < 1e-6,
"a perfect grid must score 1.0, got {}",
regions[0].score
);
assert!((regions[0].bounds.left - 0.0).abs() < 1e-6);
assert!((regions[0].bounds.right - 320.0).abs() < 1e-6);
assert!((regions[0].bounds.bottom - 70.0).abs() < 1e-6);
}
#[test]
fn a_vertical_column_of_lines_is_not_a_table() {
let lines: Vec<PageLine> = (0..6)
.map(|i| line(0.0, i as f32 * 30.0, 200.0, i as f32 * 30.0 + 20.0))
.collect();
assert_eq!(group_rows(&lines, &TableDetectorConfig::default()).len(), 6);
assert!(detect_regions(&lines, &TableDetectorConfig::default()).is_empty());
}
#[test]
fn a_paragraph_whose_lines_do_not_share_columns_is_not_a_table() {
let mut lines = Vec::new();
for i in 0..5 {
let top = i as f32 * 30.0;
let left = i as f32 * 250.0;
lines.push(line(left, top, left + 80.0, top + 20.0));
lines.push(line(left + 120.0, top, left + 200.0, top + 20.0));
}
let config = TableDetectorConfig::default();
assert_eq!(group_rows(&lines, &config).len(), 5);
assert!(detect_regions(&lines, &config).is_empty());
}
#[test]
fn two_tables_far_apart_are_reported_separately() {
let mut lines = grid(3, 3, 100.0, 20.0, 10.0, 5.0);
let offset = 5000.0;
for extra in grid(3, 3, 100.0, 20.0, 10.0, 5.0) {
let mut shifted = extra;
for point in &mut shifted.polygon {
point[1] += offset;
}
lines.push(shifted);
}
let regions = detect_regions(&lines, &TableDetectorConfig::default());
assert_eq!(regions.len(), 2);
assert_eq!(regions[0].members.len(), 9);
assert_eq!(regions[1].members.len(), 9);
assert!(regions[1].bounds.top >= offset);
}
#[test]
fn a_lone_row_is_not_a_table() {
let lines = grid(1, 4, 100.0, 20.0, 10.0, 5.0);
assert!(detect_regions(&lines, &TableDetectorConfig::default()).is_empty());
}
#[test]
fn row_grouping_absorbs_small_vertical_jitter() {
let lines = vec![
line(0.0, 0.0, 50.0, 20.0),
line(60.0, 2.0, 110.0, 22.0),
line(120.0, -2.0, 170.0, 18.0),
];
let rows = group_rows(&lines, &TableDetectorConfig::default());
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].members, vec![0, 1, 2]);
}
#[test]
fn a_wide_gap_splits_two_regions_even_when_columns_align() {
let mut lines = grid(2, 3, 100.0, 20.0, 10.0, 5.0);
for extra in grid(2, 3, 100.0, 20.0, 10.0, 5.0) {
let mut shifted = extra;
for point in &mut shifted.polygon {
point[1] += 5000.0;
}
lines.push(shifted);
}
let regions = detect_regions(&lines, &TableDetectorConfig::default());
assert_eq!(regions.len(), 2);
}
#[test]
fn shared_column_count_refuses_one_to_many_matches() {
let config = TableDetectorConfig::default();
let lines = vec![
line(0.0, 0.0, 210.0, 20.0),
line(0.0, 25.0, 100.0, 45.0),
line(110.0, 25.0, 210.0, 45.0),
];
let rows = group_rows(&lines, &config);
assert_eq!(rows.len(), 2);
assert_eq!(shared_column_count(&rows[0], &rows[1], &lines, &config), 1);
assert_eq!(shared_column_count(&rows[1], &rows[0], &lines, &config), 1);
}
#[test]
fn a_two_row_single_column_layout_is_refused_by_design() {
let lines = vec![line(0.0, 0.0, 200.0, 20.0), line(0.0, 25.0, 200.0, 45.0)];
assert!(detect_regions(&lines, &TableDetectorConfig::default()).is_empty());
}
#[test]
fn the_score_falls_when_one_row_pair_loses_a_column() {
let mut lines = grid(3, 3, 100.0, 20.0, 10.0, 5.0);
let last = lines.len() - 1;
for point in &mut lines[last].polygon {
point[0] += 400.0;
}
let regions = detect_regions(&lines, &TableDetectorConfig::default());
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].rows, 3);
assert!(
regions[0].score < 1.0 && regions[0].score > 0.5,
"expected a partial score, got {}",
regions[0].score
);
}
#[test]
fn a_wrapped_cell_does_not_split_the_region() {
let lines = vec![
line(0.0, 0.0, 100.0, 20.0),
line(200.0, 0.0, 300.0, 20.0),
line(0.0, 30.0, 100.0, 50.0),
line(200.0, 28.0, 300.0, 40.0),
line(200.0, 42.0, 300.0, 54.0),
line(0.0, 60.0, 100.0, 80.0),
line(200.0, 60.0, 300.0, 80.0),
];
let regions = detect_regions(&lines, &TableDetectorConfig::default());
assert_eq!(regions.len(), 1, "折行不得切碎表格,得到 {regions:?}");
let region = ®ions[0];
assert_eq!(region.columns, 2);
assert_eq!(
region.rows, 3,
"折行的两个细行只算填充,锚行仍是 3 个(真实行数的下界)"
);
assert_eq!(region.members.len(), 7, "7 个文本框一个都不能丢");
assert!(
region.score > 0.95,
"折行只该让行高一致性略降(折了一行的并集高 2 px),列对齐率必须仍是 1.0,\
得到 {}",
region.score
);
}
#[test]
fn a_production_log_whose_cells_wrap_is_one_region() {
let lines = vec![
line(369.0, 13.0, 590.0, 38.0), line(50.0, 47.0, 129.0, 80.0), line(523.0, 49.0, 617.0, 78.0), line(179.0, 83.0, 948.0, 107.0), line(5.0, 96.0, 134.0, 124.0), line(228.0, 111.0, 912.0, 136.0), line(4.0, 155.0, 145.0, 185.0), line(251.0, 158.0, 888.0, 180.0), line(7.0, 218.0, 154.0, 243.0), line(206.0, 217.0, 933.0, 241.0), line(8.0, 278.0, 153.0, 301.0), line(239.0, 277.0, 900.0, 302.0), line(185.0, 324.0, 955.0, 346.0), line(7.0, 338.0, 154.0, 363.0), line(469.0, 351.0, 670.0, 375.0), line(182.0, 383.0, 956.0, 407.0), line(7.0, 398.0, 154.0, 423.0), line(553.0, 410.0, 584.0, 438.0), line(178.0, 443.0, 949.0, 468.0), line(6.0, 456.0, 155.0, 485.0), line(447.0, 471.0, 692.0, 496.0), line(7.0, 518.0, 154.0, 543.0), line(326.0, 518.0, 811.0, 542.0), line(7.0, 578.0, 154.0, 603.0), line(272.0, 577.0, 867.0, 602.0), line(60.0, 631.0, 119.0, 670.0), line(425.0, 637.0, 714.0, 662.0), ];
let regions = detect_regions(&lines, &TableDetectorConfig::default());
assert_eq!(regions.len(), 1, "整张表必须是一个区域,得到 {regions:?}");
let region = ®ions[0];
assert_eq!(region.columns, 2);
assert_eq!(
region.rows, 8,
"8 个锚行;整行都折行的 3 行并入相邻区间、不计入(真实行数的下界)"
);
for (label, actual, expected) in [
("top", region.bounds.top, 47.0),
("bottom", region.bounds.bottom, 670.0),
("left", region.bounds.left, 4.0),
("right", region.bounds.right, 956.0),
] {
assert!(
(actual - expected).abs() < 2.0,
"区域 {label} 应为 {expected} 左右,得到 {actual}"
);
}
assert_eq!(
region.members.len(),
26,
"除表题外的 26 个文本框都必须进入区域,否则回填拿不到它们的文本"
);
assert!(
!region.members.contains(&0),
"表题在区域之外:它与表格的列结构无关"
);
}
#[test]
fn inconsistent_row_heights_lower_the_score() {
let even = grid(3, 3, 100.0, 20.0, 10.0, 5.0);
let even_score = detect_regions(&even, &TableDetectorConfig::default())[0].score;
let mut ragged = grid(3, 3, 100.0, 20.0, 10.0, 5.0);
for entry in ragged.iter_mut().take(3) {
entry.polygon[3][1] += 60.0;
entry.polygon[2][1] += 60.0;
}
let ragged_score = detect_regions(&ragged, &TableDetectorConfig::default())[0].score;
assert!(
ragged_score < even_score,
"ragged row heights ({ragged_score}) must score below even ones ({even_score})"
);
}
#[test]
fn the_detector_crops_the_region_and_keeps_the_source_pixels() {
let lines = grid(2, 2, 20.0, 10.0, 5.0, 5.0);
let image = canvas(200, 100);
let mut detector = TextDistributionDetector::default();
let regions = detector
.detect(&image, &lines)
.expect("detection must succeed");
assert_eq!(regions.len(), 1);
let region = ®ions[0];
assert!(
(region.score - 1.0).abs() < 1e-6,
"a perfect 2x2 grid must score 1.0, got {}",
region.score
);
assert!((region.origin[0] - 0.0).abs() < 1e-6);
assert!((region.origin[1] - 0.0).abs() < 1e-6);
assert_eq!(region.crop.width(), 46);
assert_eq!(region.crop.height(), 26);
let source = image.as_bytes();
let crop = region.crop.as_bytes();
for y in 0..region.crop.height() {
let source_y = y + region.origin[1] as usize;
let source_start = (source_y * 200 + region.origin[0] as usize) * 3;
let crop_start = y * region.crop.width() * 3;
assert_eq!(
&crop[crop_start..crop_start + region.crop.width() * 3],
&source[source_start..source_start + region.crop.width() * 3],
"crop row {y} must match the source"
);
}
}
#[test]
fn the_polygon_is_axis_aligned_and_agrees_with_the_origin() {
let lines = grid(2, 2, 20.0, 10.0, 5.0, 5.0);
let image = canvas(200, 100);
let mut detector = TextDistributionDetector::default();
let region = &detector
.detect(&image, &lines)
.expect("detection must succeed")[0];
assert_eq!(region.polygon[0], region.origin);
assert_eq!(region.polygon[1][1], region.polygon[0][1]);
assert_eq!(region.polygon[2][0], region.polygon[1][0]);
assert_eq!(region.polygon[3][1], region.polygon[2][1]);
assert_eq!(region.polygon[3][0], region.polygon[0][0]);
assert_eq!(
region.polygon[2][0] - region.polygon[0][0],
region.crop.width() as f32
);
assert_eq!(
region.polygon[2][1] - region.polygon[0][1],
region.crop.height() as f32
);
}
#[test]
fn a_region_outside_the_image_is_refused_rather_than_cropped_to_nothing() {
let outside = Bounds {
left: 500.0,
top: 500.0,
right: 600.0,
bottom: 600.0,
};
let error = clamped_rect(&outside, 100, 100).expect_err("must be refused");
assert!(
error.to_string().contains("does not intersect"),
"unexpected error: {error}"
);
}
#[test]
fn a_region_that_only_touches_the_image_is_refused() {
let degenerate = Bounds {
left: 50.0,
top: 10.0,
right: 50.0,
bottom: 90.0,
};
assert!(clamped_rect(°enerate, 100, 100).is_err());
}
#[test]
fn the_detector_needs_no_model_and_no_external_asset() {
let lines = grid(2, 2, 20.0, 10.0, 5.0, 5.0);
let image = canvas(64, 64);
let mut detector = TextDistributionDetector::default();
let regions = detector.detect(&image, &lines).expect("must succeed");
assert_eq!(regions.len(), 1);
}
#[test]
fn an_empty_page_yields_no_region() {
let image = canvas(64, 64);
let mut detector = TextDistributionDetector::default();
let regions = detector.detect(&image, &[]).expect("must succeed");
assert!(regions.is_empty());
}
fn mentions_ort_path(source: &str) -> bool {
let needle = ["ort", "::"].concat();
let bytes = source.as_bytes();
let mut from = 0;
while let Some(offset) = source[from..].find(&needle) {
let index = from + offset;
let boundary = index == 0
|| (!bytes[index - 1].is_ascii_alphanumeric() && bytes[index - 1] != b'_');
if boundary {
return true;
}
from = index + needle.len();
}
false
}
#[test]
fn the_module_constructs_no_inference_session() {
let source = include_str!("detection.rs");
let needles = [
["Ort", "Session"].concat(),
["Rapid", "OcrEngine"].concat(),
["Table", "StructureSession"].concat(),
[".on", "nx"].concat(),
["with_dimension", "Override"].concat(),
];
for needle in &needles {
assert!(
!source.contains(needle.as_str()),
"src/table/detection.rs must not mention `{needle}`: table detection is \
heuristic-only (gate G4)"
);
}
assert!(
!mentions_ort_path(source),
"src/table/detection.rs must not call into ORT: table detection is heuristic-only \
(gate G4)"
);
}
#[test]
fn the_config_refuses_degenerate_values() {
let cases = [
TableDetectorConfig {
min_columns: 1,
..TableDetectorConfig::default()
},
TableDetectorConfig {
min_rows: 0,
..TableDetectorConfig::default()
},
TableDetectorConfig {
column_overlap_ratio: 0.0,
..TableDetectorConfig::default()
},
TableDetectorConfig {
column_overlap_ratio: 1.5,
..TableDetectorConfig::default()
},
TableDetectorConfig {
max_row_gap: f32::NAN,
..TableDetectorConfig::default()
},
];
for config in cases {
assert!(
config.validate().is_err(),
"this configuration must be refused: {config:?}"
);
}
assert!(TextDistributionDetector::new(TableDetectorConfig::default()).is_ok());
}
#[test]
fn the_default_config_is_accepted_by_the_constructor() {
assert!(TextDistributionDetector::new(TableDetectorConfig::default()).is_ok());
}
#[test]
fn the_constructor_stores_the_configuration_it_was_given() {
let strict = TextDistributionDetector::new(TableDetectorConfig {
min_columns: 3,
..TableDetectorConfig::default()
})
.expect("min_columns = 3 is a valid configuration");
let image = canvas(64, 64);
let two_columns = grid(3, 2, 20.0, 10.0, 5.0, 5.0);
assert!(
strict
.clone()
.detect(&image, &two_columns)
.expect("detection must not fail")
.is_empty(),
"a two-column grid must not satisfy min_columns = 3"
);
assert_eq!(
TextDistributionDetector::default()
.detect(&image, &two_columns)
.expect("detection must not fail")
.len(),
1,
"the default min_columns = 2 accepts the same grid"
);
}
}
#[cfg(test)]
mod real_page_tests {
use std::path::Path;
use super::{
Bounds, PageLine, TableDetector, TableDetectorConfig, TableRegion, TextDistributionDetector,
};
use crate::{
config::{RecImage, RuntimeConfig},
ocr::det::detector::Detector,
ocr::pipeline::{
config::EngineConfig,
image_ops::{
PreprocessRecord, apply_vertical_padding, map_boxes_to_original,
resize_image_within_bounds,
},
},
};
const REGION_IOU: f32 = 0.5;
const DENSE_TABLE_FIXTURE: &str = "table.jpg";
const BORDER_ONLY_TABLE_FIXTURE: &str = "table_without_txt.jpg";
const KEY_VALUE_PAGE_FIXTURE: &str = "04表格与键值对.png";
const KEY_VALUE_TOP_TABLE: [f32; 4] = [160.0, 328.0, 3039.0, 647.0];
fn engine_config() -> Option<EngineConfig> {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("models")
.join("configs")
.join("test-config-small.yaml");
let config = path.is_file().then(|| EngineConfig::from_yaml_file(&path));
let config = match config {
Some(Ok(config)) => config,
Some(Err(error)) => panic!("{} must parse: {error}", path.display()),
None => {
crate::test_support::asset(
"table detection engine config (models/configs/test-config-small.yaml)",
None,
);
return None;
}
};
match config.det.model_path.as_ref() {
Some(path) if path.is_file() => {}
_ => {
crate::test_support::asset("page detector onnx", None);
return None;
}
}
Some(config)
}
fn page_lines(config: &EngineConfig, image: &RecImage) -> Vec<PageLine> {
let (proc_img, ratio_h, ratio_w) = resize_image_within_bounds(
image.clone(),
config.global.min_side_len,
config.global.max_side_len,
)
.expect("page resize must succeed");
let (padded, pad_top) = apply_vertical_padding(
proc_img,
config.global.width_height_ratio,
config.global.min_height,
)
.expect("vertical padding must succeed");
let mut detector =
Detector::new(config.det.clone(), &RuntimeConfig::default()).expect("detector opens");
let output = detector.detect(&padded).expect("det must run");
let mut boxes = output.boxes;
map_boxes_to_original(
&mut boxes,
PreprocessRecord {
ratio_h,
ratio_w,
pad_top,
pad_left: 0,
},
image.height(),
image.width(),
);
boxes
.into_iter()
.map(|polygon| PageLine {
polygon,
text: String::new(),
score: 1.0,
})
.collect()
}
fn rect_of(bounds: &Bounds) -> [f32; 4] {
[bounds.left, bounds.top, bounds.right, bounds.bottom]
}
fn iou(a: [f32; 4], b: [f32; 4]) -> f32 {
let width = (a[2].min(b[2]) - a[0].max(b[0])).max(0.0);
let height = (a[3].min(b[3]) - a[1].max(b[1])).max(0.0);
let intersection = width * height;
let area_a = (a[2] - a[0]).max(0.0) * (a[3] - a[1]).max(0.0);
let area_b = (b[2] - b[0]).max(0.0) * (b[3] - b[1]).max(0.0);
let union = area_a + area_b - intersection;
if union <= 0.0 {
0.0
} else {
intersection / union
}
}
fn run(fixture: &str) -> Option<(Vec<TableRegion>, Vec<PageLine>)> {
let config = engine_config()?;
let path = crate::test_support::table_detection_fixture(fixture)?;
let image = RecImage::from_path(&path).expect("fixture must decode");
let lines = page_lines(&config, &image);
let mut detector =
TextDistributionDetector::new(TableDetectorConfig::default()).expect("valid config");
let regions = detector
.detect(&image, &lines)
.expect("detection must succeed");
if std::env::var_os("RAPID_STRUCTURE_TABLE_DETECTION_DEBUG").is_some() {
eprintln!(
"=== {fixture} ({}x{}) lines={} regions={}",
image.width(),
image.height(),
lines.len(),
regions.len()
);
for (index, region) in regions.iter().enumerate() {
eprintln!(
" #{index} rect={:?} score={:.4}",
rect_of(&Bounds::of_polygon(®ion.polygon)),
region.score
);
}
}
Some((regions, lines))
}
fn print_bands(fixture: &str, lines: &[PageLine]) {
let config = TableDetectorConfig::default();
for (index, region) in super::detect_regions(lines, &config).iter().enumerate() {
let mut intervals: Vec<(f32, f32)> = region
.members
.iter()
.map(|member| {
let bounds = lines[*member].bounds();
(bounds.left, bounds.right)
})
.collect();
intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let mut bands: Vec<(f32, f32)> = Vec::new();
for (left, right) in intervals {
match bands.last_mut() {
Some(last) if left <= last.1 => last.1 = last.1.max(right),
_ => bands.push((left, right)),
}
}
let mut widths: Vec<f32> = bands.iter().map(|(l, r)| r - l).collect();
widths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
eprintln!(
" {fixture} region #{index} rows={} cols={} bands={} median_band_width={:.1}",
region.rows,
region.columns,
bands.len(),
widths[widths.len() / 2]
);
for pair in bands.windows(2) {
eprintln!(
" band {:.1}..{:.1} gap_to_next={:.1}",
pair[0].0,
pair[0].1,
pair[1].0 - pair[0].1
);
}
if let Some(last) = bands.last() {
eprintln!(" band {:.1}..{:.1} (last)", last.0, last.1);
}
}
}
#[test]
fn probe_real_pages() {
let debug = std::env::var_os("RAPID_STRUCTURE_TABLE_DETECTION_DEBUG").is_some();
for fixture in [
DENSE_TABLE_FIXTURE,
BORDER_ONLY_TABLE_FIXTURE,
KEY_VALUE_PAGE_FIXTURE,
] {
if debug
&& let Some(config) = engine_config()
&& let Some(path) = crate::test_support::table_detection_fixture(fixture)
{
let image = RecImage::from_path(&path).expect("fixture must decode");
print_bands(fixture, &page_lines(&config, &image));
}
run(fixture);
}
}
#[test]
fn a_dense_text_table_is_located_as_one_region() {
let Some((regions, lines)) = run(DENSE_TABLE_FIXTURE) else {
return;
};
assert!(
lines.len() >= 50,
"the fixture must actually contain text lines, got {}",
lines.len()
);
assert_eq!(
regions.len(),
1,
"a single dense table must yield exactly one region, got {regions:?}"
);
let expected = [0.0, 0.0, 371.0, 293.0];
let actual = rect_of(&Bounds::of_polygon(®ions[0].polygon));
let overlap = iou(actual, expected);
assert!(
overlap >= 0.9,
"the located region {actual:?} must cover the whole table {expected:?}, IoU = {overlap}"
);
assert!(
regions[0].score >= 0.9,
"a clean grid must score high, got {}",
regions[0].score
);
assert_eq!(regions[0].crop.width(), (actual[2] - actual[0]) as usize);
assert_eq!(regions[0].crop.height(), (actual[3] - actual[1]) as usize);
assert_eq!(regions[0].origin, [actual[0], actual[1]]);
}
#[test]
fn a_table_without_any_text_is_not_located_by_design() {
let Some((regions, lines)) = run(BORDER_ONLY_TABLE_FIXTURE) else {
return;
};
assert!(
lines.len() <= 2,
"this fixture is supposed to be text free, but det found {} lines",
lines.len()
);
assert!(
regions.is_empty(),
"a text-free table cannot be located from text distribution, got {regions:?}"
);
}
#[test]
fn a_multi_block_page_still_merges_side_by_side_blocks() {
let Some((regions, lines)) = run(KEY_VALUE_PAGE_FIXTURE) else {
return;
};
assert!(
lines.len() >= 40,
"the fixture must contain text, got {}",
lines.len()
);
let internal = super::detect_regions(&lines, &TableDetectorConfig::default());
assert_eq!(
internal.len(),
2,
"expected the top table plus one merged block, got {internal:?}"
);
let top = &internal[0];
assert_eq!(
(top.rows, top.columns),
(5, 5),
"the top statistics table is 5x5 including its header row"
);
assert!(
top.bounds.bottom < 1000.0,
"the top table must stay in the upper half, got bottom {}",
top.bounds.bottom
);
let top_overlap = iou(rect_of(&top.bounds), KEY_VALUE_TOP_TABLE);
assert!(
top_overlap >= REGION_IOU,
"the top table must match the box measured from the page's own grid lines \
{KEY_VALUE_TOP_TABLE:?}, got {:?} with IoU = {top_overlap}",
rect_of(&top.bounds)
);
let merged = &internal[1];
assert!(
merged.bounds.left <= 200.0 && merged.bounds.right >= 2900.0,
"the known false merge must span both side-by-side blocks, got {:?}",
rect_of(&merged.bounds)
);
assert_eq!(
regions.len(),
2,
"the public detector must report the same two regions"
);
}
#[test]
fn the_locator_costs_far_less_than_the_page_budget() {
let Some(config) = engine_config() else {
return;
};
let Some(path) = crate::test_support::table_detection_fixture(KEY_VALUE_PAGE_FIXTURE)
else {
return;
};
let image = RecImage::from_path(&path).expect("fixture must decode");
let lines = page_lines(&config, &image);
let mut detector =
TextDistributionDetector::new(TableDetectorConfig::default()).expect("valid config");
let _ = detector.detect(&image, &lines).expect("warmup");
let started = std::time::Instant::now();
for _ in 0..10 {
let _ = detector.detect(&image, &lines).expect("detection");
}
let per_call_ms = started.elapsed().as_secs_f64() * 100.0;
eprintln!(
"locator cost on {} lines: {per_call_ms:.3} ms/call",
lines.len()
);
assert!(
per_call_ms < 50.0,
"the locator must stay negligible next to a ~1000 ms page budget, got {per_call_ms} ms"
);
}
}