use ndarray::Array4;
use crate::{
config::RecImage,
error::{RapidOcrError, Result},
vision::image_backend::resize_image,
};
pub const TABLE_STRUCTURE_MEAN: [f32; 3] = [0.485, 0.456, 0.406];
pub const TABLE_STRUCTURE_STD: [f32; 3] = [0.229, 0.224, 0.225];
pub const TABLE_STRUCTURE_SCALE: f32 = 1.0 / 255.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TableCanvas {
pub side_len: usize,
pub original_height: usize,
pub original_width: usize,
pub resize_height: usize,
pub resize_width: usize,
pub ratio: f32,
}
impl TableCanvas {
pub fn original_dimensions(&self) -> (f32, f32) {
(self.original_height as f32, self.original_width as f32)
}
pub fn content_bbox(&self) -> [f32; 4] {
[
0.0,
0.0,
self.resize_width as f32,
self.resize_height as f32,
]
}
}
pub fn resize_dimensions(
height: usize,
width: usize,
side_len: usize,
) -> Result<(usize, usize, f32)> {
if height == 0 || width == 0 {
return Err(RapidOcrError::InvalidImage(
"table structure preprocessing got an image with a zero side".to_string(),
));
}
if side_len == 0 {
return Err(RapidOcrError::Config(
"table structure canvas side length must be greater than zero".to_string(),
));
}
let longest = height.max(width) as f64;
let ratio = side_len as f64 / longest;
let resize_height = (height as f64 * ratio) as usize;
let resize_width = (width as f64 * ratio) as usize;
if resize_height == 0 || resize_width == 0 {
return Err(RapidOcrError::InvalidImage(format!(
"table structure resize collapsed to {resize_width}x{resize_height} for a \
{width}x{height} image on a {side_len} canvas"
)));
}
if resize_height > side_len || resize_width > side_len {
return Err(RapidOcrError::InvalidImage(format!(
"table structure resize produced {resize_width}x{resize_height}, which does not fit \
the {side_len}x{side_len} canvas"
)));
}
Ok((resize_height, resize_width, ratio as f32))
}
pub fn table_structure_input(
image: &RecImage,
side_len: usize,
) -> Result<(Array4<f32>, TableCanvas)> {
let (original_height, original_width) = (image.height(), image.width());
let (resize_height, resize_width, ratio) =
resize_dimensions(original_height, original_width, side_len)?;
let resized = resize_image(image, resize_width, resize_height)?;
let resized_owned = resized.as_bgr_cow();
let resized = resized_owned.as_ref();
debug_assert_eq!(resized.len(), resize_height * resize_width * 3);
let mut tensor = Array4::<f32>::zeros((1, 3, side_len, side_len));
for channel in 0..3usize {
let mean = TABLE_STRUCTURE_MEAN[channel];
let std = TABLE_STRUCTURE_STD[channel];
let mut plane = tensor.slice_mut(ndarray::s![0, channel, .., ..]);
for y in 0..resize_height {
for x in 0..resize_width {
let raw = resized[(y * resize_width + x) * 3 + channel] as f32;
plane[[y, x]] = (raw * TABLE_STRUCTURE_SCALE - mean) / std;
}
}
}
let canvas = TableCanvas {
side_len,
original_height,
original_width,
resize_height,
resize_width,
ratio,
};
Ok((tensor, canvas))
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(width: usize, height: usize, bgr: [u8; 3]) -> RecImage {
let mut data = Vec::with_capacity(width * height * 3);
for _ in 0..width * height {
data.extend_from_slice(&bgr);
}
RecImage::from_bgr_u8(width, height, data).expect("synthetic image must be valid")
}
#[test]
fn resize_uses_truncation_like_the_reference() {
let (h, w, ratio) = resize_dimensions(1000, 500, 488).expect("valid");
assert_eq!(h, 488);
assert_eq!(w, 244);
assert!((ratio - 0.488).abs() < 1e-6);
}
#[test]
fn a_square_image_fills_the_canvas() {
let (h, w, _) = resize_dimensions(488, 488, 488).expect("valid");
assert_eq!((h, w), (488, 488));
}
#[test]
fn a_degenerate_resize_is_rejected_rather_than_silently_cropped() {
let error = resize_dimensions(1, 100_000, 488).expect_err("must reject");
assert!(
matches!(error, RapidOcrError::InvalidImage(_)),
"unexpected error: {error}"
);
}
#[test]
fn a_zero_sided_image_is_rejected() {
assert!(resize_dimensions(0, 10, 488).is_err());
assert!(resize_dimensions(10, 0, 488).is_err());
}
#[test]
fn real_pixels_are_normalized_and_padding_stays_exactly_zero() {
let image = solid(2, 1, [255, 255, 255]);
let (tensor, canvas) = table_structure_input(&image, 4).expect("valid");
assert_eq!((canvas.resize_height, canvas.resize_width), (2, 4));
for channel in 0..3 {
let expected = (255.0 * TABLE_STRUCTURE_SCALE - TABLE_STRUCTURE_MEAN[channel])
/ TABLE_STRUCTURE_STD[channel];
for y in 0..2 {
for x in 0..4 {
let value = tensor[[0, channel, y, x]];
assert!(
(value - expected).abs() < 1e-5,
"channel {channel} at ({y},{x}) is {value}, expected {expected}"
);
}
}
for y in 2..4 {
for x in 0..4 {
assert_eq!(
tensor[[0, channel, y, x]],
0.0,
"channel {channel} at ({y},{x}) must be the padding zero"
);
}
}
}
}
#[test]
fn the_canvas_is_left_top_aligned_so_the_right_and_bottom_are_exactly_zero() {
let image = solid(1, 2, [10, 20, 30]);
let (tensor, canvas) = table_structure_input(&image, 4).expect("valid");
assert_eq!((canvas.resize_height, canvas.resize_width), (4, 2));
for channel in 0..3 {
for y in 0..4 {
for x in 2..4 {
assert_eq!(
tensor[[0, channel, y, x]],
0.0,
"channel {channel} at ({y},{x}) must be the padding zero"
);
}
}
}
}
#[test]
fn channels_are_normalized_in_bgr_order() {
let image = solid(4, 4, [255, 0, 0]);
let (tensor, _) = table_structure_input(&image, 4).expect("valid");
let expected_blue = (255.0 * TABLE_STRUCTURE_SCALE - 0.485) / 0.229;
let expected_black_g = (0.0 - 0.456) / 0.224;
assert!((tensor[[0, 0, 0, 0]] - expected_blue).abs() < 1e-5);
assert!((tensor[[0, 1, 0, 0]] - expected_black_g).abs() < 1e-5);
let if_it_were_rgb = (255.0 * TABLE_STRUCTURE_SCALE - 0.406) / 0.225;
assert!(
(tensor[[0, 0, 0, 0]] - if_it_were_rgb).abs() > 0.3,
"channel 0 matches the RGB interpretation too, so this test cannot tell BGR from RGB"
);
}
#[test]
fn the_tensor_shape_is_one_by_three_by_side_by_side() {
let image = solid(300, 100, [1, 2, 3]);
let (tensor, canvas) = table_structure_input(&image, 488).expect("valid");
assert_eq!(tensor.shape(), &[1, 3, 488, 488]);
assert_eq!(canvas.original_dimensions(), (100.0, 300.0));
assert_eq!(canvas.content_bbox(), [0.0, 0.0, 488.0, 162.0]);
}
}