use crate::error::{RapidOcrError, Result};
pub const SOS_CLASS_INDEX: usize = 0;
pub fn eos_class_index(token_count: usize) -> usize {
token_count + 1
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructureVocabulary {
tokens: Vec<String>,
}
impl StructureVocabulary {
pub fn new(tokens: Vec<String>) -> Result<Self> {
if tokens.is_empty() {
return Err(RapidOcrError::Config(
"table structure vocabulary must not be empty".to_string(),
));
}
for (index, token) in tokens.iter().enumerate() {
if token == "sos" || token == "eos" {
return Err(RapidOcrError::Config(format!(
"table structure vocabulary token {index} is the reserved special token \
`{token}`; sos/eos are added by the decoder and must not appear in the \
model's `character` metadata"
)));
}
}
Ok(Self { tokens })
}
pub fn tokens(&self) -> &[String] {
&self.tokens
}
pub fn class_count(&self) -> usize {
self.tokens.len() + 2
}
pub fn sos_class_index(&self) -> usize {
SOS_CLASS_INDEX
}
pub fn eos_class_index(&self) -> usize {
eos_class_index(self.tokens.len())
}
pub fn token_at(&self, class_index: usize) -> Result<&str> {
if class_index == self.sos_class_index() {
return Err(RapidOcrError::Decode(
"table structure class `sos` reached the token mapping; sos must be skipped, not \
mapped to a fragment"
.to_string(),
));
}
if class_index == self.eos_class_index() {
return Err(RapidOcrError::Decode(
"table structure class `eos` reached the token mapping; eos must terminate the \
sequence, not be mapped to a fragment"
.to_string(),
));
}
self.tokens
.get(class_index - 1)
.map(String::as_str)
.ok_or_else(|| {
RapidOcrError::Decode(format!(
"table structure class index {class_index} is out of range for a vocabulary of \
{} fragments ({} classes)",
self.tokens.len(),
self.class_count()
))
})
}
pub fn is_cell_start(&self, token: &str) -> bool {
token == "<td>" || token == "<td" || token == "<td></td>"
}
}
pub fn argmax_structure(logits: &[f32], steps: usize) -> Result<Vec<(usize, f32)>> {
if steps == 0 {
return Err(RapidOcrError::Decode(
"table structure argmax needs at least one step".to_string(),
));
}
if !logits.len().is_multiple_of(steps) {
return Err(RapidOcrError::Decode(format!(
"table structure logits have {} values which is not a multiple of {steps} steps",
logits.len()
)));
}
let classes = logits.len() / steps;
if classes == 0 {
return Err(RapidOcrError::Decode(
"table structure argmax got logits with zero classes".to_string(),
));
}
let mut steps_out = Vec::with_capacity(steps);
for step in 0..steps {
let row = &logits[step * classes..(step + 1) * classes];
let mut best_index = 0usize;
let mut best_score = f32::NEG_INFINITY;
for (index, &value) in row.iter().enumerate() {
if value > best_score {
best_score = value;
best_index = index;
}
}
if !best_score.is_finite() {
return Err(RapidOcrError::Decode(format!(
"table structure step {step} has no finite logit; the structure output looks \
uninitialised"
)));
}
steps_out.push((best_index, best_score));
}
Ok(steps_out)
}
#[derive(Debug, Clone, PartialEq)]
pub struct TokenStep {
pub step_index: usize,
pub class_index: usize,
pub fragment: String,
pub score: f32,
pub is_cell_start: bool,
}
pub fn decode_token_steps(
vocabulary: &StructureVocabulary,
argmaxed: &[(usize, f32)],
) -> Result<(Vec<TokenStep>, StructureTermination)> {
let sos = vocabulary.sos_class_index();
let eos = vocabulary.eos_class_index();
let mut out = Vec::with_capacity(argmaxed.len());
let mut termination = StructureTermination::Exhausted;
for (index, &(class_index, score)) in argmaxed.iter().enumerate() {
if index > 0 && class_index == eos {
termination = StructureTermination::Eos;
break;
}
if class_index == sos || class_index == eos {
continue;
}
let fragment = vocabulary.token_at(class_index)?.to_string();
let is_cell_start = vocabulary.is_cell_start(&fragment);
out.push(TokenStep {
step_index: index,
class_index,
fragment,
score,
is_cell_start,
});
}
Ok((out, termination))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LogicCell {
pub row_start: usize,
pub row_end: usize,
pub col_start: usize,
pub col_end: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GridSize {
pub rows: usize,
pub cols: usize,
}
impl GridSize {
pub const fn cell_count(&self) -> usize {
self.rows * self.cols
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GridCoverage {
pub cells: usize,
pub covered: usize,
}
impl GridCoverage {
pub const fn holes(&self) -> usize {
self.cells.saturating_sub(self.covered)
}
pub const fn is_complete(&self) -> bool {
self.holes() == 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StructureTermination {
Eos,
Exhausted,
}
impl StructureTermination {
pub const fn is_truncated(self) -> bool {
matches!(self, Self::Exhausted)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CellSlot {
pub cell_index: usize,
pub tag: String,
pub rowspan: usize,
pub colspan: usize,
pub score: f32,
pub bbox: Option<[f32; 4]>,
pub logic: LogicCell,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StructureNode {
pub tag: String,
pub rowspan: Option<usize>,
pub colspan: Option<usize>,
pub cell_index: Option<usize>,
pub bbox: Option<[f32; 4]>,
pub children: Vec<StructureNode>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StructureResult {
pub root: StructureNode,
pub cells: Vec<CellSlot>,
pub grid: GridSize,
pub coverage: GridCoverage,
pub termination: StructureTermination,
pub confidence: f32,
}
impl StructureResult {
pub const fn is_grid_complete(&self) -> bool {
self.coverage.is_complete()
}
pub const fn is_truncated(&self) -> bool {
self.termination.is_truncated()
}
}
pub fn is_blank_polygon(polygon: &[f32; 8]) -> bool {
polygon.iter().all(|&value| value == 0.0)
}
pub fn polygon_to_bbox(polygon: &[f32; 8]) -> [f32; 4] {
crate::vision::geometry::aabb_from_flat8(polygon)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TableShape {
pub original_height: f32,
pub original_width: f32,
pub ratio: f32,
pub max_len: f32,
}
impl TableShape {
pub fn new(original_height: f32, original_width: f32, max_len: f32) -> Result<Self> {
if !(original_height > 0.0 && original_width > 0.0 && max_len > 0.0) {
return Err(RapidOcrError::Decode(format!(
"table shape needs positive dimensions, got h={original_height} w={original_width} \
max_len={max_len}"
)));
}
let longest = original_height.max(original_width);
Ok(Self {
original_height,
original_width,
ratio: max_len / longest,
max_len,
})
}
pub fn denormalize_polygon(&self, normalized: &[f32; 8]) -> [f32; 8] {
let mut out = *normalized;
for x in out.iter_mut().step_by(2) {
*x *= self.original_width;
}
for y in out.iter_mut().skip(1).step_by(2) {
*y *= self.original_height;
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RescaleRatios {
pub w_ratio: f32,
pub h_ratio: f32,
}
pub fn rescale_ratios(original_width: f32, original_height: f32, max_len: f32) -> RescaleRatios {
let ratio = (max_len / original_height).min(max_len / original_width);
RescaleRatios {
w_ratio: max_len / (original_width * ratio),
h_ratio: max_len / (original_height * ratio),
}
}
pub fn rescale_polygon(polygon: &[f32; 8], ratios: RescaleRatios) -> [f32; 8] {
let mut out = *polygon;
for x in out.iter_mut().step_by(2) {
*x *= ratios.w_ratio;
}
for y in out.iter_mut().skip(1).step_by(2) {
*y *= ratios.h_ratio;
}
out
}
pub fn decode_logic_points(
rows: &[Vec<(usize, usize)>],
) -> Result<(Vec<LogicCell>, GridSize, GridCoverage)> {
let total: usize = rows.iter().map(Vec::len).sum();
let mut logic = Vec::with_capacity(total);
let mut occupied: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
let mut max_rows = 0usize;
let mut max_cols = 0usize;
for (row_index, row) in rows.iter().enumerate() {
let mut current_col = 0usize;
for &(rowspan, colspan) in row {
if rowspan == 0 || colspan == 0 {
return Err(RapidOcrError::Decode(
"table cell spans must be at least 1; rowspan/colspan attributes in the \
vocabulary start at 2, so a 0 means the tag was parsed wrong"
.to_string(),
));
}
while occupied.contains(&(row_index, current_col)) {
current_col += 1;
}
let cell = LogicCell {
row_start: row_index,
row_end: row_index + rowspan - 1,
col_start: current_col,
col_end: current_col + colspan - 1,
};
for r in cell.row_start..=cell.row_end {
for c in cell.col_start..=cell.col_end {
occupied.insert((r, c));
}
}
max_rows = max_rows.max(cell.row_end + 1);
max_cols = max_cols.max(cell.col_end + 1);
logic.push(cell);
current_col += colspan;
}
}
let cells = max_rows * max_cols;
let coverage = GridCoverage {
cells,
covered: occupied.len(),
};
Ok((
logic,
GridSize {
rows: max_rows,
cols: max_cols,
},
coverage,
))
}
pub fn parse_cell_tag(tag: &str) -> Result<(Option<usize>, Option<usize>)> {
if !tag.starts_with("<td") {
return Err(RapidOcrError::Decode(format!(
"expected a cell tag starting with `<td`, got `{tag}`"
)));
}
let mut rowspan = None;
let mut colspan = None;
for attribute in ["rowspan", "colspan"] {
let Some(position) = tag.find(attribute) else {
continue;
};
let rest = &tag[position + attribute.len()..];
let Some(equals) = rest.find('=') else {
return Err(RapidOcrError::Decode(format!(
"table cell tag `{tag}` has `{attribute}` without a value"
)));
};
let rest = rest[equals + 1..].trim_start();
let quote = rest.chars().next();
let digits: String = match quote {
Some(q @ ('"' | '\'')) => {
let after = &rest[q.len_utf8()..];
after.chars().take_while(|c| c.is_ascii_digit()).collect()
}
_ => rest.chars().take_while(|c| c.is_ascii_digit()).collect(),
};
let value: usize = digits.parse().map_err(|_| {
RapidOcrError::Decode(format!(
"table cell tag `{tag}` has a non-numeric `{attribute}` value"
))
})?;
if value == 0 {
return Err(RapidOcrError::Decode(format!(
"table cell tag `{tag}` has `{attribute}=0`; spans are 1-based"
)));
}
match attribute {
"rowspan" => rowspan = Some(value),
_ => colspan = Some(value),
}
}
Ok((rowspan, colspan))
}
pub fn tokens_to_structure(
vocabulary: &StructureVocabulary,
steps: &[TokenStep],
termination: StructureTermination,
cell_polygons: &[[f32; 8]],
) -> Result<StructureResult> {
let expected_polygons = steps.iter().filter(|step| step.is_cell_start).count();
if cell_polygons.len() != expected_polygons {
return Err(RapidOcrError::Decode(format!(
"table structure has {expected_polygons} cell-start tokens but {} cell boxes were \
supplied; the box output and the structure output are not aligned",
cell_polygons.len()
)));
}
let mut root = StructureNode {
tag: "<table>".to_string(),
rowspan: None,
colspan: None,
cell_index: None,
bbox: None,
children: Vec::new(),
};
let mut stack: Vec<usize> = Vec::new();
let mut cells: Vec<CellSlot> = Vec::new();
let mut row_spans: Vec<Vec<(usize, usize)>> = vec![Vec::new()];
let mut polygon_cursor = 0usize;
let score_sum: f64 = steps.iter().map(|step| f64::from(step.score)).sum();
let mut position = 0usize;
while position < steps.len() {
let step = &steps[position];
match vocabulary.token_at(step.class_index) {
Ok(token) if token == step.fragment => {}
_ => {
return Err(RapidOcrError::Decode(format!(
"table structure step {position} claims class {} with fragment `{}`, which \
is not that class in the model vocabulary",
step.class_index, step.fragment
)));
}
}
if step.is_cell_start {
let (tag, consumed) = concat_cell_tag(steps, position)?;
let (rowspan, colspan) = parse_cell_tag(&tag)?;
let polygon = cell_polygons[polygon_cursor];
polygon_cursor += 1;
let cell_index = cells.len();
cells.push(CellSlot {
cell_index,
tag: tag.clone(),
rowspan: rowspan.unwrap_or(1),
colspan: colspan.unwrap_or(1),
score: step.score,
bbox: if is_blank_polygon(&polygon) {
None
} else {
Some(polygon_to_bbox(&polygon))
},
logic: LogicCell {
row_start: 0,
row_end: 0,
col_start: 0,
col_end: 0,
},
});
row_spans
.last_mut()
.expect("row_spans always keeps at least the current row")
.push((cells[cell_index].rowspan, cells[cell_index].colspan));
push_child(
&mut root,
&stack,
StructureNode {
tag,
rowspan: rowspan.filter(|&value| value > 1),
colspan: colspan.filter(|&value| value > 1),
cell_index: Some(cell_index),
bbox: cells[cell_index].bbox,
children: Vec::new(),
},
);
position += consumed;
continue;
}
match step.fragment.as_str() {
"</td>" => {}
fragment if fragment.starts_with("</") => {
let Some(open_index) = stack.pop() else {
return Err(RapidOcrError::Decode(format!(
"table structure closes `{fragment}` without a matching open tag"
)));
};
let open_tag = node_at(&root, &stack).children[open_index].tag.as_str();
if !closes(open_tag, fragment) {
return Err(RapidOcrError::Decode(format!(
"table structure closes `{fragment}` but the innermost open tag is \
`{open_tag}`"
)));
}
if fragment == "</tr>" {
row_spans.push(Vec::new());
}
}
"<thead>" | "<tbody>" | "<tr>" => {
let node = StructureNode {
tag: step.fragment.clone(),
rowspan: None,
colspan: None,
cell_index: None,
bbox: None,
children: Vec::new(),
};
let index = push_child(&mut root, &stack, node);
stack.push(index);
}
fragment => {
return Err(RapidOcrError::Decode(format!(
"table structure has a fragment `{fragment}` that is neither a cell start nor \
a tag this decoder knows; a bare `>` or ` colspan=…` here means the `<td` \
that should have opened it is missing"
)));
}
}
position += 1;
}
if !stack.is_empty() {
return Err(RapidOcrError::Decode(format!(
"table structure ends with {} unclosed tag(s)",
stack.len()
)));
}
let (logic, grid, coverage) = decode_logic_points(&row_spans)?;
for (cell, cell_logic) in cells.iter_mut().zip(logic.iter()) {
cell.logic = *cell_logic;
}
let confidence = if steps.is_empty() {
0.0
} else {
(score_sum / steps.len() as f64) as f32
};
Ok(StructureResult {
root,
cells,
grid,
coverage,
termination,
confidence,
})
}
fn concat_cell_tag(steps: &[TokenStep], start: usize) -> Result<(String, usize)> {
let start_fragment = &steps[start].fragment;
if start_fragment == "<td></td>" {
return Ok((start_fragment.clone(), 1));
}
if start_fragment != "<td" {
return Err(RapidOcrError::Decode(format!(
"table cell start token must be `<td` or `<td></td>`, got `{start_fragment}`"
)));
}
let mut tag = String::from("<td");
for (offset, step) in steps[start + 1..].iter().enumerate() {
tag.push_str(&step.fragment);
if step.fragment.starts_with('>') {
return Ok((tag, offset + 2));
}
if step.is_cell_start
|| step.fragment.starts_with("<tr>")
|| step.fragment.starts_with("</tr>")
{
return Err(RapidOcrError::Decode(format!(
"table cell tag starting at `<td` was never closed by a `>` fragment; the \
structure sequence looks truncated (got `{tag}`)"
)));
}
}
Err(RapidOcrError::Decode(format!(
"table cell tag `{tag}` runs off the end of the structure sequence without a `>` fragment"
)))
}
fn push_child(root: &mut StructureNode, stack: &[usize], node: StructureNode) -> usize {
let parent = node_at_mut(root, stack);
parent.children.push(node);
parent.children.len() - 1
}
fn node_at<'a>(root: &'a StructureNode, stack: &[usize]) -> &'a StructureNode {
let mut node = root;
for &index in stack {
node = &node.children[index];
}
node
}
fn node_at_mut<'a>(root: &'a mut StructureNode, stack: &[usize]) -> &'a mut StructureNode {
let mut node = root;
for &index in stack {
node = &mut node.children[index];
}
node
}
fn closes(open_tag: &str, close_fragment: &str) -> bool {
match close_fragment.strip_prefix("</") {
Some(name_with_bracket) => open_tag.strip_prefix('<') == Some(name_with_bracket),
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn real_vocabulary() -> StructureVocabulary {
let tokens: Vec<String> = vec![
"<thead>", "</thead>", "<tbody>", "</tbody>", "<tr>", "</tr>", "<td", ">", "</td>",
]
.into_iter()
.map(str::to_string)
.chain((2..=20).map(|n| format!(" colspan=\"{n}\"")))
.chain((2..=20).map(|n| format!(" rowspan=\"{n}\"")))
.chain(std::iter::once("<td></td>".to_string()))
.collect();
assert_eq!(tokens.len(), 48);
StructureVocabulary::new(tokens).expect("real vocabulary must build")
}
fn steps_of(vocabulary: &StructureVocabulary, fragments: &[&str]) -> Vec<TokenStep> {
fragments
.iter()
.enumerate()
.map(|(step_index, fragment)| {
let class_index = vocabulary
.tokens()
.iter()
.position(|token| token == fragment)
.map(|index| index + 1)
.unwrap_or_else(|| panic!("`{fragment}` is not in the vocabulary"));
TokenStep {
step_index,
class_index,
fragment: (*fragment).to_string(),
score: 1.0,
is_cell_start: vocabulary.is_cell_start(fragment),
}
})
.collect()
}
fn polygon(x0: f32, y0: f32, x1: f32, y1: f32) -> [f32; 8] {
[x0, y0, x1, y0, x1, y1, x0, y1]
}
fn structure_of(
vocabulary: &StructureVocabulary,
steps: &[TokenStep],
polygons: &[[f32; 8]],
) -> Result<StructureResult> {
tokens_to_structure(vocabulary, steps, StructureTermination::Eos, polygons)
}
fn blank_polygon() -> [f32; 8] {
[0.0; 8]
}
#[test]
fn special_tokens_surround_the_vocabulary() {
let vocabulary = real_vocabulary();
assert_eq!(vocabulary.class_count(), 50);
assert_eq!(vocabulary.sos_class_index(), 0);
assert_eq!(vocabulary.eos_class_index(), 49);
}
#[test]
fn class_index_one_maps_to_the_first_fragment() {
let vocabulary = real_vocabulary();
assert_eq!(vocabulary.token_at(1).unwrap(), "<thead>");
assert_eq!(vocabulary.token_at(7).unwrap(), "<td");
assert_eq!(vocabulary.token_at(8).unwrap(), ">");
assert_eq!(vocabulary.token_at(9).unwrap(), "</td>");
assert_eq!(vocabulary.token_at(10).unwrap(), " colspan=\"2\"");
assert_eq!(vocabulary.token_at(48).unwrap(), "<td></td>");
}
#[test]
fn special_classes_cannot_be_mapped_to_fragments() {
let vocabulary = real_vocabulary();
let sos = vocabulary
.token_at(0)
.expect_err("sos must not map to a fragment");
assert!(sos.to_string().contains("sos"), "{sos}");
let eos = vocabulary
.token_at(49)
.expect_err("eos must not map to a fragment");
assert!(eos.to_string().contains("eos"), "{eos}");
let out_of_range = vocabulary.token_at(50).expect_err("50 is out of range");
assert!(
out_of_range.to_string().contains("out of range"),
"{out_of_range}"
);
}
#[test]
fn vocabulary_rejects_reserved_special_tokens() {
let error = StructureVocabulary::new(vec!["<td".to_string(), "sos".to_string()])
.expect_err("`sos` must not appear in the metadata vocabulary");
assert!(error.to_string().contains("reserved"), "{error}");
assert!(StructureVocabulary::new(Vec::new()).is_err());
}
#[test]
fn eos_terminates_the_sequence() {
let vocabulary = real_vocabulary();
let argmaxed = vec![(7, 0.9), (8, 0.8), (9, 0.7), (49, 0.6), (7, 0.5), (8, 0.4)];
let (steps, termination) = decode_token_steps(&vocabulary, &argmaxed).unwrap();
assert_eq!(termination, StructureTermination::Eos);
assert_eq!(steps.len(), 3);
assert_eq!(steps[2].fragment, "</td>");
}
#[test]
fn eos_at_step_zero_does_not_terminate() {
let vocabulary = real_vocabulary();
let argmaxed = vec![(49, 0.6), (7, 0.9), (8, 0.8)];
let (steps, termination) = decode_token_steps(&vocabulary, &argmaxed).unwrap();
assert_eq!(termination, StructureTermination::Exhausted);
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].fragment, "<td");
assert_eq!(steps[1].fragment, ">");
}
#[test]
fn sos_is_skipped_wherever_it_appears() {
let vocabulary = real_vocabulary();
let argmaxed = vec![(0, 0.5), (7, 0.9), (0, 0.4), (8, 0.8), (0, 0.3)];
let (steps, _termination) = decode_token_steps(&vocabulary, &argmaxed).unwrap();
assert_eq!(steps.len(), 2);
assert!(steps.iter().all(|step| step.class_index != 0));
}
#[test]
fn argmax_picks_the_largest_class_and_keeps_its_score() {
let logits = vec![
0.1, 0.9, 0.0, 0.0, 0.5, 0.2, 0.7, 0.1, 0.0, 0.0, 0.0, 0.0, ];
let result = argmax_structure(&logits, 3).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].0, 1);
assert!((result[0].1 - 0.9).abs() < 1e-6);
assert_eq!(result[1].0, 2);
assert!((result[1].1 - 0.7).abs() < 1e-6);
assert_eq!(result[2].0, 0);
}
#[test]
fn argmax_breaks_ties_towards_the_lower_class() {
let result = argmax_structure(&[0.5, 0.5, 0.5], 1).unwrap();
assert_eq!(result[0].0, 0);
}
#[test]
fn argmax_rejects_misaligned_logits() {
let error = argmax_structure(&[0.1, 0.2, 0.3, 0.4, 0.5], 2)
.expect_err("5 values cannot split into 2 steps");
assert!(error.to_string().contains("not a multiple"), "{error}");
assert!(argmax_structure(&[0.1], 0).is_err());
}
#[test]
fn argmax_rejects_all_negative_infinity() {
let error = argmax_structure(&[f32::NEG_INFINITY, f32::NEG_INFINITY], 1)
.expect_err("no finite logit means the tensor was not initialised");
assert!(error.to_string().contains("finite"), "{error}");
}
#[test]
fn plain_cell_tag_is_assembled_from_fragments() {
let vocabulary = real_vocabulary();
let steps = steps_of(&vocabulary, &["<tr>", "<td", ">", "</td>", "</tr>"]);
let result = structure_of(&vocabulary, &steps, &[polygon(1.0, 2.0, 3.0, 4.0)]).unwrap();
assert_eq!(result.cells.len(), 1);
assert_eq!(result.cells[0].tag, "<td>");
assert_eq!(result.cells[0].rowspan, 1);
assert_eq!(result.cells[0].colspan, 1);
}
#[test]
fn spanned_cell_tag_carries_both_attributes() {
let vocabulary = real_vocabulary();
let steps = steps_of(
&vocabulary,
&[
"<tr>",
"<td",
" colspan=\"2\"",
" rowspan=\"3\"",
">",
"</td>",
"</tr>",
],
);
let result = structure_of(&vocabulary, &steps, &[polygon(0.0, 0.0, 2.0, 2.0)]).unwrap();
assert_eq!(result.cells[0].tag, "<td colspan=\"2\" rowspan=\"3\">");
assert_eq!(result.cells[0].colspan, 2);
assert_eq!(result.cells[0].rowspan, 3);
}
#[test]
fn empty_cell_is_a_single_fragment() {
let vocabulary = real_vocabulary();
let steps = steps_of(&vocabulary, &["<tr>", "<td></td>", "</tr>"]);
let result = structure_of(&vocabulary, &steps, &[blank_polygon()]).unwrap();
assert_eq!(result.cells[0].tag, "<td></td>");
assert!(result.cells[0].bbox.is_none());
}
#[test]
fn unclosed_cell_tag_is_rejected() {
let vocabulary = real_vocabulary();
let steps = steps_of(&vocabulary, &["<tr>", "<td", " colspan=\"2\"", "</tr>"]);
let error = structure_of(&vocabulary, &steps, &[blank_polygon()])
.expect_err("a `<td` without `>` must not be silently closed");
assert!(error.to_string().contains("never closed"), "{error}");
}
#[test]
fn cell_tag_running_off_the_end_is_rejected() {
let vocabulary = real_vocabulary();
let steps = steps_of(&vocabulary, &["<td", " colspan=\"2\""]);
let error = structure_of(&vocabulary, &steps, &[blank_polygon()]).expect_err("truncated");
assert!(error.to_string().contains("runs off the end"), "{error}");
}
#[test]
fn cell_box_count_must_match_cell_start_count() {
let vocabulary = real_vocabulary();
let steps = steps_of(&vocabulary, &["<td", ">", "</td>", "<td", ">", "</td>"]);
let error = structure_of(&vocabulary, &steps, &[blank_polygon()])
.expect_err("two cell starts need two boxes");
assert!(error.to_string().contains("not aligned"), "{error}");
}
#[test]
fn truncation_is_carried_separately_from_the_tokens() {
let vocabulary = real_vocabulary();
let steps = steps_of(&vocabulary, &["<tr>", "<td", ">", "</tr>"]);
let complete = tokens_to_structure(
&vocabulary,
&steps,
StructureTermination::Eos,
&[blank_polygon()],
)
.unwrap();
let cut_off = tokens_to_structure(
&vocabulary,
&steps,
StructureTermination::Exhausted,
&[blank_polygon()],
)
.unwrap();
assert_eq!(complete.cells, cut_off.cells);
assert_eq!(complete.coverage, cut_off.coverage);
assert!(!complete.is_truncated());
assert!(cut_off.is_truncated());
}
#[test]
fn polygon_collapses_to_its_axis_aligned_box() {
let bbox = polygon_to_bbox(&[4.0, 1.0, 9.0, 1.0, 9.0, 7.0, 4.0, 7.0]);
assert_eq!(bbox, [4.0, 1.0, 9.0, 7.0]);
}
#[test]
fn blank_polygon_needs_all_eight_zero_coordinates() {
assert!(is_blank_polygon(&[0.0; 8]));
assert!(!is_blank_polygon(&[
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0001
]));
}
#[test]
fn denormalization_multiplies_by_the_original_crop_size() {
let shape = TableShape::new(300.0, 600.0, 488.0).unwrap();
assert!((shape.ratio - 488.0 / 600.0).abs() < 1e-6);
let polygon = shape.denormalize_polygon(&[0.5, 0.5, 1.0, 0.5, 1.0, 1.0, 0.5, 1.0]);
assert_eq!(
polygon,
[300.0, 150.0, 600.0, 150.0, 600.0, 300.0, 300.0, 300.0]
);
}
#[test]
fn table_shape_rejects_degenerate_sizes() {
assert!(TableShape::new(0.0, 10.0, 488.0).is_err());
assert!(TableShape::new(10.0, 10.0, 0.0).is_err());
}
#[test]
fn wide_tables_leave_the_horizontal_axis_unscaled() {
let ratios = rescale_ratios(600.0, 300.0, 488.0);
assert!((ratios.w_ratio - 1.0).abs() < 1e-6, "{:?}", ratios);
assert!((ratios.h_ratio - 2.0).abs() < 1e-6, "{:?}", ratios);
}
#[test]
fn tall_tables_leave_the_vertical_axis_unscaled() {
let ratios = rescale_ratios(300.0, 600.0, 488.0);
assert!((ratios.h_ratio - 1.0).abs() < 1e-6, "{:?}", ratios);
assert!((ratios.w_ratio - 2.0).abs() < 1e-6, "{:?}", ratios);
}
#[test]
fn square_tables_are_unscaled_on_both_axes() {
let ratios = rescale_ratios(400.0, 400.0, 488.0);
assert!((ratios.w_ratio - 1.0).abs() < 1e-6);
assert!((ratios.h_ratio - 1.0).abs() < 1e-6);
}
#[test]
fn rescale_scales_the_two_axes_independently() {
let ratios = rescale_ratios(600.0, 300.0, 488.0);
let polygon = rescale_polygon(&[1.0, 2.0, 3.0, 2.0, 3.0, 4.0, 1.0, 4.0], ratios);
assert_eq!(polygon, [1.0, 4.0, 3.0, 4.0, 3.0, 8.0, 1.0, 8.0]);
}
#[test]
fn the_two_coordinate_stages_compose_into_one_uniform_scale() {
let normalized = [0.25, 0.5, 0.75, 0.5, 0.75, 0.9, 0.25, 0.9];
for &(height, width) in &[
(300.0f32, 600.0f32),
(600.0, 300.0),
(400.0, 400.0),
(100.0, 977.0),
] {
let max_side = height.max(width);
let shape = TableShape::new(height, width, 488.0).unwrap();
let ratios = rescale_ratios(width, height, 488.0);
let composed = rescale_polygon(&shape.denormalize_polygon(&normalized), ratios);
for (index, (got, unit)) in composed.iter().zip(normalized.iter()).enumerate() {
let want = unit * max_side;
assert!(
(got - want).abs() < 1e-3,
"coordinate {index} composed to {got}, expected {want} \
(normalized {unit} * max({height}, {width}))"
);
}
}
}
#[test]
fn a_simple_grid_walks_left_to_right() {
let (logic, grid, coverage) = decode_logic_points(&[vec![(1, 1), (1, 1), (1, 1)]]).unwrap();
assert_eq!(
logic,
vec![
LogicCell {
row_start: 0,
row_end: 0,
col_start: 0,
col_end: 0
},
LogicCell {
row_start: 0,
row_end: 0,
col_start: 1,
col_end: 1
},
LogicCell {
row_start: 0,
row_end: 0,
col_start: 2,
col_end: 2
},
]
);
assert_eq!(grid, GridSize { rows: 1, cols: 3 });
assert_eq!(
coverage,
GridCoverage {
cells: 3,
covered: 3
}
);
assert!(coverage.is_complete());
}
#[test]
fn colspan_pushes_later_cells_to_the_right() {
let (logic, grid, coverage) = decode_logic_points(&[vec![(1, 2), (1, 1)]]).unwrap();
assert_eq!(logic[0].col_end, 1);
assert_eq!(logic[1].col_start, 2);
assert_eq!(grid.cols, 3);
assert_eq!(coverage.holes(), 0);
}
#[test]
fn rowspan_blocks_the_cell_below_it() {
let (logic, grid, coverage) =
decode_logic_points(&[vec![(2, 1), (1, 1)], vec![(1, 1)]]).unwrap();
assert_eq!(
logic[0],
LogicCell {
row_start: 0,
row_end: 1,
col_start: 0,
col_end: 0
}
);
assert_eq!(
logic[1],
LogicCell {
row_start: 0,
row_end: 0,
col_start: 1,
col_end: 1
}
);
assert_eq!(
logic[2],
LogicCell {
row_start: 1,
row_end: 1,
col_start: 1,
col_end: 1
}
);
assert_eq!(grid, GridSize { rows: 2, cols: 2 });
assert_eq!(
coverage,
GridCoverage {
cells: 4,
covered: 4
}
);
}
#[test]
fn a_declared_span_that_does_not_tile_the_grid_leaves_a_hole() {
let (logic, grid, coverage) =
decode_logic_points(&[vec![(3, 1), (1, 1)], vec![(1, 1)]]).unwrap();
assert_eq!(logic.len(), 3);
assert_eq!(grid, GridSize { rows: 3, cols: 2 });
assert_eq!(
coverage,
GridCoverage {
cells: 6,
covered: 5
}
);
assert_eq!(coverage.holes(), 1);
assert!(!coverage.is_complete());
}
#[test]
fn overlapping_rectangles_are_counted_once_not_by_span_area() {
let (logic, grid, coverage) =
decode_logic_points(&[vec![(1, 1), (2, 1)], vec![(1, 2)]]).unwrap();
assert_eq!(logic.len(), 3);
assert_eq!(grid, GridSize { rows: 2, cols: 2 });
assert_eq!(
coverage,
GridCoverage {
cells: 4,
covered: 4
}
);
let span_area: usize = logic
.iter()
.map(|cell| (cell.row_end - cell.row_start + 1) * (cell.col_end - cell.col_start + 1))
.sum();
assert_eq!(span_area, 5, "the rectangles really do overlap");
assert_ne!(coverage.covered, span_area);
assert!(coverage.is_complete());
}
#[test]
fn single_diagonal_cell_yields_a_one_by_one_grid() {
let (logic, grid, coverage) = decode_logic_points(&[vec![(1, 1)]]).unwrap();
assert_eq!(logic.len(), 1);
assert_eq!(grid, GridSize { rows: 1, cols: 1 });
assert!(coverage.is_complete());
}
#[test]
fn zero_spans_are_rejected() {
let error = decode_logic_points(&[vec![(0, 1)]]).expect_err("spans are 1-based");
assert!(error.to_string().contains("at least 1"), "{error}");
}
#[test]
fn empty_cell_list_yields_an_empty_grid() {
let (logic, grid, coverage) = decode_logic_points(&[]).unwrap();
assert!(logic.is_empty());
assert_eq!(grid, GridSize { rows: 0, cols: 0 });
assert_eq!(
coverage,
GridCoverage {
cells: 0,
covered: 0
}
);
assert!(coverage.is_complete());
}
#[test]
fn cell_tag_parsing_reads_spans_in_either_order() {
assert_eq!(parse_cell_tag("<td>").unwrap(), (None, None));
assert_eq!(parse_cell_tag("<td></td>").unwrap(), (None, None));
assert_eq!(
parse_cell_tag("<td colspan=\"2\">").unwrap(),
(None, Some(2))
);
assert_eq!(
parse_cell_tag("<td rowspan=\"3\">").unwrap(),
(Some(3), None)
);
assert_eq!(
parse_cell_tag("<td rowspan=\"3\" colspan=\"4\">").unwrap(),
(Some(3), Some(4))
);
}
#[test]
fn cell_tag_parsing_rejects_malformed_attributes() {
let error = parse_cell_tag("<tr>").expect_err("only `<td` tags are cells");
assert!(error.to_string().contains("<td"), "{error}");
assert!(parse_cell_tag("<td colspan=\"0\">").is_err());
assert!(parse_cell_tag("<td colspan=\"\">").is_err());
assert!(parse_cell_tag("<td colspan>").is_err());
}
#[test]
fn tree_nests_rows_under_the_table_and_cells_under_rows() {
let vocabulary = real_vocabulary();
let steps = steps_of(
&vocabulary,
&[
"<tr>", "<td", ">", "</td>", "<td", ">", "</td>", "</tr>", "<tr>", "<td", ">",
"</td>", "</tr>",
],
);
let boxes = [
polygon(0.0, 0.0, 1.0, 1.0),
polygon(1.0, 0.0, 2.0, 1.0),
polygon(0.0, 1.0, 1.0, 2.0),
];
let result = structure_of(&vocabulary, &steps, &boxes).unwrap();
assert_eq!(result.root.tag, "<table>");
assert_eq!(result.root.children.len(), 2);
assert_eq!(result.root.children[0].tag, "<tr>");
assert_eq!(result.root.children[0].children.len(), 2);
assert_eq!(result.root.children[0].children[0].tag, "<td>");
assert_eq!(result.root.children[0].children[0].cell_index, Some(0));
assert_eq!(result.root.children[1].children[0].cell_index, Some(2));
assert_eq!(result.cells.len(), 3);
assert_eq!(result.grid, GridSize { rows: 2, cols: 2 });
}
#[test]
fn thead_and_tbody_are_preserved_in_the_tree() {
let vocabulary = real_vocabulary();
let steps = steps_of(
&vocabulary,
&[
"<thead>", "<tr>", "<td", ">", "</td>", "</tr>", "</thead>", "<tbody>", "</tbody>",
],
);
let result = structure_of(&vocabulary, &steps, &[polygon(0.0, 0.0, 1.0, 1.0)]).unwrap();
assert_eq!(result.root.children[0].tag, "<thead>");
assert_eq!(result.root.children[0].children[0].tag, "<tr>");
assert_eq!(result.root.children[1].tag, "<tbody>");
}
#[test]
fn an_unmatched_close_tag_is_rejected() {
let vocabulary = real_vocabulary();
let steps = steps_of(&vocabulary, &["</tr>", "<tr>"]);
let error = structure_of(&vocabulary, &steps, &[]).expect_err("unmatched `</tr>`");
assert!(
error.to_string().contains("without a matching open tag"),
"{error}"
);
}
#[test]
fn unclosed_tags_at_the_end_are_rejected() {
let vocabulary = real_vocabulary();
let steps = steps_of(&vocabulary, &["<tr>", "<td", ">", "</td>"]);
let error = structure_of(&vocabulary, &steps, &[polygon(0.0, 0.0, 1.0, 1.0)]).unwrap_err();
assert!(error.to_string().contains("unclosed"), "{error}");
}
#[test]
fn a_stray_close_fragment_is_rejected() {
let vocabulary = real_vocabulary();
let steps = steps_of(&vocabulary, &["<tr>", ">"]);
let error = structure_of(&vocabulary, &steps, &[]).expect_err("stray `>`");
assert!(
error.to_string().contains("neither a cell start"),
"{error}"
);
}
#[test]
fn confidence_is_the_mean_of_every_step_score() {
let vocabulary = real_vocabulary();
let mut steps = steps_of(&vocabulary, &["<tr>", "<td", ">", "</td>", "</tr>"]);
for (index, step) in steps.iter_mut().enumerate() {
step.score = index as f32;
}
let result = structure_of(&vocabulary, &steps, &[polygon(0.0, 0.0, 1.0, 1.0)]).unwrap();
assert!(
(result.confidence - 2.0).abs() < 1e-6,
"{}",
result.confidence
);
}
#[test]
fn an_empty_sequence_decodes_to_an_empty_table() {
let vocabulary = real_vocabulary();
let result = structure_of(&vocabulary, &[], &[]).unwrap();
assert_eq!(result.root.tag, "<table>");
assert!(result.root.children.is_empty());
assert!(result.cells.is_empty());
assert_eq!(result.confidence, 0.0);
}
#[test]
fn full_pipeline_from_logits_to_cells() {
let vocabulary = real_vocabulary();
let wanted: Vec<&str> = vec!["<tr>", "<td", " colspan=\"2\"", ">", "</td>", "</tr>"];
let classes = vocabulary.class_count();
let mut logits = vec![0.0f32; wanted.len() * classes];
for (step, fragment) in wanted.iter().enumerate() {
let class_index = vocabulary
.tokens()
.iter()
.position(|token| token == fragment)
.unwrap()
+ 1;
for value in logits[step * classes..(step + 1) * classes].iter_mut() {
*value = 0.1;
}
let slot = &mut logits[step * classes + class_index];
*slot = 0.9;
}
let argmaxed = argmax_structure(&logits, wanted.len()).unwrap();
let (steps, _termination) = decode_token_steps(&vocabulary, &argmaxed).unwrap();
assert_eq!(steps.len(), wanted.len());
let result = structure_of(&vocabulary, &steps, &[polygon(0.0, 0.0, 10.0, 5.0)]).unwrap();
assert_eq!(result.cells.len(), 1);
assert_eq!(result.cells[0].tag, "<td colspan=\"2\">");
assert_eq!(result.cells[0].colspan, 2);
assert_eq!(result.cells[0].bbox, Some([0.0, 0.0, 10.0, 5.0]));
assert_eq!(result.grid, GridSize { rows: 1, cols: 2 });
}
}
#[cfg(test)]
mod golden_tests {
use super::*;
use crate::test_support::fixture_dir;
use serde_json::Value;
use std::path::{Path, PathBuf};
const COORD_TOLERANCE: f32 = 1e-6;
const UNIFORM_SCALE_TOLERANCE: f32 = 1e-4;
const CONFIDENCE_TOLERANCE: f32 = 1e-5;
const MAX_LEN: f32 = 488.0;
fn golden_dir() -> PathBuf {
fixture_dir("table-golden")
}
fn manifest() -> Value {
let path = golden_dir().join("manifest.json");
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
serde_json::from_str(&text).expect("golden manifest is valid json")
}
fn load_npy(path: &Path) -> (Vec<usize>, Vec<f32>) {
let bytes =
std::fs::read(path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
assert!(
bytes.starts_with(b"\x93NUMPY"),
"{} is not a npy file",
path.display()
);
let major = bytes[6];
let header_len = match major {
1 => u16::from_le_bytes([bytes[8], bytes[9]]) as usize,
2 | 3 => u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize,
other => panic!("unsupported npy version {other}"),
};
let header_start = if major == 1 { 10 } else { 12 };
let header = std::str::from_utf8(&bytes[header_start..header_start + header_len])
.expect("npy header should be utf-8");
assert!(header.contains("'descr': '<f4'"), "golden npy must be f32");
assert!(
header.contains("'fortran_order': False"),
"golden npy must be C order"
);
let shape_start = header
.find("'shape': (")
.expect("npy header should contain shape")
+ "'shape': (".len();
let shape_end = header[shape_start..]
.find(')')
.expect("npy shape should end")
+ shape_start;
let shape: Vec<usize> = header[shape_start..shape_end]
.split(',')
.filter_map(|part| {
let part = part.trim();
if part.is_empty() {
None
} else {
Some(part.parse().expect("npy shape item"))
}
})
.collect();
let data_start = header_start + header_len;
let values: Vec<f32> = bytes[data_start..]
.as_chunks::<4>()
.0
.iter()
.map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
.collect();
assert_eq!(
values.len(),
shape.iter().product::<usize>(),
"npy payload length disagrees with its header"
);
(shape, values)
}
fn number(value: &Value, context: &str) -> f64 {
value
.as_f64()
.unwrap_or_else(|| panic!("{context} should be a json number"))
}
fn count(value: &Value, context: &str) -> usize {
value
.as_u64()
.unwrap_or_else(|| panic!("{context} should be a json integer")) as usize
}
fn reference_polygons(manifest: &Value, key: &str) -> Vec<[f32; 8]> {
manifest["reference"]["cells"]
.as_array()
.expect("golden reference cells")
.iter()
.map(|cell| {
let items = cell[key]
.as_array()
.unwrap_or_else(|| panic!("golden cell should carry `{key}`"));
assert_eq!(items.len(), 8, "golden cell `{key}` should hold 8 values");
let mut polygon = [0.0f32; 8];
for (slot, item) in polygon.iter_mut().zip(items) {
*slot = number(item, key) as f32;
}
polygon
})
.collect()
}
fn golden_vocabulary(manifest: &Value) -> StructureVocabulary {
let tokens: Vec<String> = manifest["vocabulary"]["tokens"]
.as_array()
.expect("golden vocabulary tokens")
.iter()
.map(|token| {
token
.as_str()
.expect("vocabulary token should be a json string")
.to_string()
})
.collect();
StructureVocabulary::new(tokens).expect("golden vocabulary builds")
}
fn decode_golden() -> (Value, Vec<TokenStep>, StructureResult) {
let manifest = manifest();
let vocabulary = golden_vocabulary(&manifest);
let (structure_shape, structure_logits) = load_npy(&golden_dir().join("structure.npy"));
assert_eq!(
structure_shape.len(),
3,
"structure.npy should be (N, L, C)"
);
let steps_count = structure_shape[1];
assert_eq!(
structure_shape[2],
vocabulary.class_count(),
"model class count disagrees with the golden vocabulary"
);
let argmaxed = argmax_structure(&structure_logits, steps_count).expect("golden argmax");
let (steps, _termination) =
decode_token_steps(&vocabulary, &argmaxed).expect("golden token steps");
let (boxes_shape, boxes) = load_npy(&golden_dir().join("boxes.npy"));
assert_eq!(
boxes_shape,
vec![structure_shape[0], steps_count, 8],
"boxes.npy should be (N, L, 8) and share L with structure.npy"
);
let height = number(&manifest["source_image"]["height"], "source height") as f32;
let width = number(&manifest["source_image"]["width"], "source width") as f32;
let shape = TableShape::new(height, width, MAX_LEN).expect("golden table shape");
let ratios = rescale_ratios(width, height, MAX_LEN);
let cell_polygons: Vec<[f32; 8]> = steps
.iter()
.filter(|step| step.is_cell_start)
.map(|step| {
let offset = step.step_index * 8;
let mut raw = [0.0f32; 8];
raw.copy_from_slice(&boxes[offset..offset + 8]);
rescale_polygon(&shape.denormalize_polygon(&raw), ratios)
})
.collect();
let result = tokens_to_structure(
&vocabulary,
&steps,
StructureTermination::Eos,
&cell_polygons,
)
.expect("golden structure should decode");
(manifest, steps, result)
}
fn assert_polygon_close(actual: &[f32; 8], expected: &[f32; 8], context: &str) {
assert_eq!(actual.len(), expected.len());
for (index, (actual, expected)) in actual.iter().zip(expected).enumerate() {
let delta = (actual - expected).abs();
assert!(
delta <= COORD_TOLERANCE,
"{context}: coordinate {index} differs by {delta} (actual {actual} vs expected \
{expected})"
);
}
}
#[test]
fn golden_vocabulary_matches_the_reference_class_layout() {
let manifest = manifest();
assert_eq!(count(&manifest["vocabulary"]["token_count"], "tokens"), 48);
assert_eq!(count(&manifest["vocabulary"]["class_count"], "classes"), 50);
assert_eq!(count(&manifest["vocabulary"]["sos_class_index"], "sos"), 0);
assert_eq!(count(&manifest["vocabulary"]["eos_class_index"], "eos"), 49);
let vocabulary = golden_vocabulary(&manifest);
assert_eq!(vocabulary.tokens().len(), 48);
assert_eq!(vocabulary.class_count(), 50);
assert_eq!(vocabulary.sos_class_index(), SOS_CLASS_INDEX);
assert_eq!(vocabulary.eos_class_index(), 49);
assert_eq!(eos_class_index(vocabulary.tokens().len()), 49);
assert!(vocabulary.token_at(0).is_err());
assert!(vocabulary.token_at(49).is_err());
assert!(vocabulary.is_cell_start("<td>"));
assert!(vocabulary.is_cell_start("<td"));
assert!(vocabulary.is_cell_start("<td></td>"));
assert!(!vocabulary.is_cell_start(">"));
assert!(!vocabulary.is_cell_start("</td>"));
assert!(!vocabulary.is_cell_start("<tr>"));
}
#[test]
fn golden_preprocess_constants_match_the_reference() {
let manifest = manifest();
let values: Vec<f32> = manifest["preprocess"]["mean"]
.as_array()
.expect("preprocess mean")
.iter()
.map(|value| number(value, "mean") as f32)
.collect();
assert_eq!(values, vec![0.485, 0.456, 0.406]);
let values: Vec<f32> = manifest["preprocess"]["std"]
.as_array()
.expect("preprocess std")
.iter()
.map(|value| number(value, "std") as f32)
.collect();
assert_eq!(values, vec![0.229, 0.224, 0.225]);
let scale = number(&manifest["preprocess"]["scale"], "scale") as f32;
assert!((scale - 1.0 / 255.0).abs() <= f32::EPSILON);
assert_eq!(
number(&manifest["preprocess"]["max_len"], "max_len") as f32,
MAX_LEN
);
assert_eq!(
manifest["preprocess"]["order"].as_str().expect("order"),
"resize -> normalize -> pad(top-left, zeros) -> to_chw"
);
assert_eq!(manifest["input"]["channel_order"].as_str(), Some("BGR"));
assert_eq!(count(&manifest["input"]["height"], "input height"), 385);
assert_eq!(count(&manifest["input"]["width"], "input width"), 488);
}
#[test]
fn golden_token_stream_matches_the_reference_decoder() {
let (manifest, steps, _) = decode_golden();
let expected: Vec<&str> = manifest["reference"]["tokens"]
.as_array()
.expect("reference tokens")
.iter()
.map(|token| token.as_str().expect("token should be a string"))
.collect();
let fragments: Vec<&str> = steps.iter().map(|step| step.fragment.as_str()).collect();
assert_eq!(fragments, expected);
assert_eq!(steps.len(), 114);
assert!(
steps
.windows(2)
.all(|pair| pair[0].step_index < pair[1].step_index)
);
assert_eq!(steps[0].step_index, 0);
assert_eq!(steps[0].fragment, "<tbody>");
assert_eq!(steps.last().unwrap().fragment, "</tbody>");
let (structure_shape, structure_logits) = load_npy(&golden_dir().join("structure.npy"));
let raw = argmax_structure(&structure_logits, structure_shape[1]).expect("golden argmax");
assert_eq!(raw.len(), 116);
assert_eq!(raw[114].0, eos_class_index(48));
assert_eq!(raw[115].0, SOS_CLASS_INDEX);
assert_eq!(raw[0].0, steps[0].class_index);
}
#[test]
fn golden_logic_grid_matches_the_reference_decoder() {
let (manifest, _, result) = decode_golden();
let points = manifest["reference"]["logic_points"]
.as_array()
.expect("reference logic points");
assert_eq!(result.cells.len(), points.len());
for (index, (cell, point)) in result.cells.iter().zip(points).enumerate() {
assert_eq!(point.as_array().expect("logic point").len(), 4);
let expected = LogicCell {
row_start: count(&point[0], "row_start"),
row_end: count(&point[1], "row_end"),
col_start: count(&point[2], "col_start"),
col_end: count(&point[3], "col_end"),
};
assert_eq!(cell.logic, expected, "cell {index} logic cell");
}
assert_eq!(
result.grid,
GridSize {
rows: count(&manifest["reference"]["grid"]["rows"], "rows"),
cols: count(&manifest["reference"]["grid"]["cols"], "cols"),
}
);
assert_eq!(result.grid, GridSize { rows: 16, cols: 5 });
}
#[test]
fn golden_cell_boxes_match_the_reference_pipeline_cell_by_cell() {
let (manifest, steps, result) = decode_golden();
let raw = reference_polygons(&manifest, "raw_normalized");
let decoded = reference_polygons(&manifest, "after_bbox_decode");
let rescaled = reference_polygons(&manifest, "after_rescale");
assert_eq!(raw.len(), 80);
assert_eq!(result.cells.len(), rescaled.len());
assert_eq!(
count(&manifest["reference"]["dropped_blank_count"], "dropped"),
0
);
let height = number(&manifest["source_image"]["height"], "height") as f32;
let width = number(&manifest["source_image"]["width"], "width") as f32;
let shape = TableShape::new(height, width, MAX_LEN).expect("table shape");
let ratios = rescale_ratios(width, height, MAX_LEN);
let cell_steps: Vec<&TokenStep> = steps.iter().filter(|step| step.is_cell_start).collect();
assert_eq!(cell_steps.len(), result.cells.len());
for (index, cell) in result.cells.iter().enumerate() {
assert_polygon_close(
&shape.denormalize_polygon(&raw[index]),
&decoded[index],
&format!("cell {index} after `_bbox_decode`"),
);
assert_polygon_close(
&rescale_polygon(&decoded[index], ratios),
&rescaled[index],
&format!("cell {index} after `rescale_cell_bboxes`"),
);
let record = &manifest["reference"]["cells"][index];
assert_eq!(
cell.tag,
record["tag"].as_str().expect("cell tag"),
"cell {index} tag"
);
assert_eq!(cell.rowspan, 1, "cell {index} rowspan");
assert_eq!(cell.colspan, 1, "cell {index} colspan");
let score = number(&record["score"], "cell score") as f32;
assert!(
(cell.score - score).abs() <= CONFIDENCE_TOLERANCE,
"cell {index} score {} vs {score}",
cell.score
);
assert_eq!(
cell_steps[index].step_index,
count(&record["step"], "cell step"),
"cell {index} token step"
);
assert_eq!(
record["blank"].as_bool().expect("blank flag"),
cell.bbox.is_none(),
"cell {index} blank flag"
);
match cell.bbox {
None => {}
Some(bbox) => {
let expected = polygon_to_bbox(&rescaled[index]);
for (axis, (actual, expected)) in bbox.iter().zip(&expected).enumerate() {
let delta = (actual - expected).abs();
assert!(
delta <= COORD_TOLERANCE,
"cell {index} bbox axis {axis} differs by {delta} (actual {actual} vs \
expected {expected})"
);
}
}
}
}
}
#[test]
fn golden_confidence_is_the_mean_of_every_step_score() {
let (manifest, steps, result) = decode_golden();
let expected = number(&manifest["reference"]["confidence"], "confidence") as f32;
assert!(
(result.confidence - expected).abs() <= CONFIDENCE_TOLERANCE,
"confidence {} vs {expected}",
result.confidence
);
let mean = steps.iter().map(|step| f64::from(step.score)).sum::<f64>() / steps.len() as f64;
assert!((f64::from(result.confidence) - mean).abs() <= f64::from(CONFIDENCE_TOLERANCE));
}
#[test]
fn golden_structure_tree_has_one_tbody_with_sixteen_rows_of_five_cells() {
let (_, _, result) = decode_golden();
assert_eq!(result.root.tag, "<table>");
assert_eq!(result.root.children.len(), 1);
let body = &result.root.children[0];
assert_eq!(body.tag, "<tbody>");
assert_eq!(body.children.len(), 16);
for (row_index, row) in body.children.iter().enumerate() {
assert_eq!(row.tag, "<tr>", "row {row_index}");
assert_eq!(row.children.len(), 5, "row {row_index}");
for (cell_index, cell) in row.children.iter().enumerate() {
assert_eq!(cell.tag, "<td></td>", "row {row_index} cell {cell_index}");
assert!(cell.children.is_empty(), "cells are leaves");
assert!(cell.cell_index.is_some());
}
}
}
#[test]
fn golden_pins_the_coordinate_space_to_the_original_crop() {
let manifest = manifest();
let height = number(&manifest["source_image"]["height"], "height") as f32;
let width = number(&manifest["source_image"]["width"], "width") as f32;
assert_eq!(
(height, width),
(293.0, 371.0),
"golden source image changed"
);
let uniform = height.max(width);
assert_eq!(uniform, 371.0);
let w_ratio = number(&manifest["geometry"]["w_ratio"], "w_ratio") as f32;
let h_ratio = number(&manifest["geometry"]["h_ratio"], "h_ratio") as f32;
assert_eq!(w_ratio, 1.0, "横表的 `w_ratio` 应为 1");
assert!((h_ratio - uniform / height).abs() <= UNIFORM_SCALE_TOLERANCE);
assert!((width * w_ratio - uniform).abs() <= UNIFORM_SCALE_TOLERANCE);
assert!((height * h_ratio - uniform).abs() <= UNIFORM_SCALE_TOLERANCE);
assert_eq!(
number(&manifest["geometry"]["uniform_scale_max_h_w"], "uniform") as f32,
uniform
);
let raw = reference_polygons(&manifest, "raw_normalized");
let rescaled = reference_polygons(&manifest, "after_rescale");
for (index, (raw, rescaled)) in raw.iter().zip(&rescaled).enumerate() {
for (axis, expected) in raw.iter().enumerate() {
let expected = expected * uniform;
let delta = (rescaled[axis] - expected).abs();
assert!(
delta <= UNIFORM_SCALE_TOLERANCE,
"cell {index} coordinate {axis}: {} vs {expected} (×max(h,w), delta {delta})",
rescaled[axis]
);
}
}
let canvas_overflow = raw.iter().any(|polygon| {
polygon.iter().enumerate().any(|(axis, value)| {
let bound = if axis % 2 == 0 { width } else { height };
value * MAX_LEN > bound
})
});
assert!(
canvas_overflow,
"「×488」假设应当在原图外,否则它与 crop 空间无法区分"
);
for (index, polygon) in rescaled.iter().enumerate() {
for (axis, value) in polygon.iter().enumerate() {
let bound = if axis % 2 == 0 { width } else { height };
assert!(
*value >= -COORD_TOLERANCE && *value <= bound + COORD_TOLERANCE,
"cell {index} coordinate {axis} = {value} leaves the {bound} crop"
);
}
}
}
}